feat: outbox model (#4)

This commit is contained in:
Cody Tseng
2024-11-07 21:42:51 +08:00
committed by GitHub
parent bd3078bcd0
commit ead1710392
21 changed files with 171 additions and 59 deletions

View File

@@ -1,4 +1,4 @@
import { toHashtag } from '@renderer/lib/url'
import { toHashtag } from '@renderer/lib/link'
import { SecondaryPageLink } from '@renderer/PageManager'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {

View File

@@ -1,5 +1,5 @@
import { useFetchEventById } from '@renderer/hooks'
import { toNoStrudelNote } from '@renderer/lib/url'
import { toNoStrudelNote } from '@renderer/lib/link'
import { kinds } from 'nostr-tools'
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'

View File

@@ -1,6 +1,6 @@
import { Event } from 'nostr-tools'
import { Card } from '@renderer/components/ui/card'
import { toNote } from '@renderer/lib/url'
import { toNote } from '@renderer/lib/link'
import { useSecondaryPage } from '@renderer/PageManager'
import Note from '../Note'
import { useFetchEventById } from '@renderer/hooks'

View File

@@ -1,7 +1,6 @@
import { Button } from '@renderer/components/ui/button'
import { isReplyNoteEvent } from '@renderer/lib/event'
import { cn } from '@renderer/lib/utils'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
@@ -9,9 +8,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import NoteCard from '../NoteCard'
export default function NoteList({
relayUrls,
filter = {},
className
}: {
relayUrls: string[]
filter?: Filter
className?: string
}) {
@@ -22,7 +23,6 @@ export default function NoteList({
const [initialized, setInitialized] = useState(false)
const observer = useRef<IntersectionObserver | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const { relayUrls } = useRelaySettings()
const noteFilter = useMemo(() => {
return {
kinds: [kinds.ShortTextNote, kinds.Repost],
@@ -87,7 +87,7 @@ export default function NoteList({
}, [until, initialized])
const loadMore = async () => {
const events = await client.fetchEvents({ ...noteFilter, until })
const events = await client.fetchEvents(relayUrls, { ...noteFilter, until })
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
if (sortedEvents.length === 0) {
setHasMore(false)

View File

@@ -2,6 +2,7 @@ import { createReactionDraftEvent } from '@renderer/lib/draft-event'
import { cn } from '@renderer/lib/utils'
import { useNostr } from '@renderer/providers/NostrProvider'
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
import client from '@renderer/services/client.service'
import { Heart } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
@@ -50,8 +51,9 @@ export default function LikeButton({
])
if (liked) return
const targetRelayList = await client.fetchRelayList(event.pubkey)
const reaction = createReactionDraftEvent(event)
await publish(reaction)
await publish(reaction, targetRelayList.read)
markNoteAsLiked(event.id)
} catch (error) {
console.error('like failed', error)

View File

@@ -13,6 +13,7 @@ import { createRepostDraftEvent } from '@renderer/lib/draft-event'
import { cn } from '@renderer/lib/utils'
import { useNostr } from '@renderer/providers/NostrProvider'
import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
import client from '@renderer/services/client.service'
import { Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
@@ -60,8 +61,9 @@ export default function RepostButton({
])
if (reposted) return
const targetRelayList = await client.fetchRelayList(event.pubkey)
const repost = createRepostDraftEvent(event)
await publish(repost)
await publish(repost, targetRelayList.read)
markNoteAsReposted(event.id)
} catch (error) {
console.error('repost failed', error)

View File

@@ -1,5 +1,6 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
import client from '@renderer/services/client.service'
import { CircleX } from 'lucide-react'
@@ -43,11 +44,11 @@ export default function RelayUrls({ groupName }: { groupName: string }) {
const saveNewRelayUrl = () => {
if (newRelayUrl === '') return
const normalizedUrl = normalizeURL(newRelayUrl)
const normalizedUrl = normalizeUrl(newRelayUrl)
if (relays.some(({ url }) => url === normalizedUrl)) {
return setNewRelayUrlError('already exists')
}
if (/^wss?:\/\/.+$/.test(normalizedUrl) === false) {
if (!isWebsocketUrl(normalizedUrl)) {
return setNewRelayUrlError('invalid URL')
}
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
@@ -130,16 +131,3 @@ function RelayUrl({
</div>
)
}
// copy from nostr-tools/utils
function normalizeURL(url: string): string {
if (url.indexOf('://') === -1) url = 'wss://' + url
const p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:'))
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}

View File

@@ -20,7 +20,8 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
const loadMore = async () => {
setLoading(true)
const events = await client.fetchEvents({
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read, {
'#e': [event.id],
kinds: [1],
limit: 100,

View File

@@ -3,7 +3,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/compone
import { Skeleton } from '@renderer/components/ui/skeleton'
import { useFetchProfile } from '@renderer/hooks'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { toProfile } from '@renderer/lib/url'
import { toProfile } from '@renderer/lib/link'
import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard'

View File

@@ -1,6 +1,6 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
import { useFetchProfile } from '@renderer/hooks'
import { toProfile } from '@renderer/lib/url'
import { toProfile } from '@renderer/lib/link'
import { cn } from '@renderer/lib/utils'
import { SecondaryPageLink } from '@renderer/PageManager'
import ProfileCard from '../ProfileCard'

View File

@@ -0,0 +1,23 @@
import { TRelayList } from '@renderer/types'
import { useEffect, useState } from 'react'
import client from '@renderer/services/client.service'
export function useFetchRelayList(pubkey?: string | null) {
const [relayList, setRelayList] = useState<TRelayList>({ write: [], read: [] })
useEffect(() => {
const fetchRelayList = async () => {
if (!pubkey) return
try {
const relayList = await client.fetchRelayList(pubkey)
setRelayList(relayList)
} catch (err) {
console.error(err)
}
}
fetchRelayList()
}, [])
return relayList
}

View File

@@ -17,7 +17,7 @@ import {
import { Input } from '@renderer/components/ui/input'
import { useFetchProfile } from '@renderer/hooks'
import { generateImageByPubkey } from '@renderer/lib/pubkey'
import { toProfile } from '@renderer/lib/url'
import { toProfile } from '@renderer/lib/link'
import { useSecondaryPage } from '@renderer/PageManager'
import { useNostr } from '@renderer/providers/NostrProvider'
import { LogIn } from 'lucide-react'

View File

@@ -0,0 +1,7 @@
import { Event } from 'nostr-tools'
export const toProfile = (pubkey: string) => ({ pageName: 'profile', props: { pubkey } })
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })

View File

@@ -1,7 +1,16 @@
import { Event } from 'nostr-tools'
export function isWebsocketUrl(url: string): boolean {
return /^wss?:\/\/.+$/.test(url)
}
export const toProfile = (pubkey: string) => ({ pageName: 'profile', props: { pubkey } })
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })
// copy from nostr-tools/utils
export function normalizeUrl(url: string): string {
if (url.indexOf('://') === -1) url = 'wss://' + url
const p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:'))
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}

View File

@@ -1,10 +1,12 @@
import NoteList from '@renderer/components/NoteList'
import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
export default function NoteListPage() {
const { relayUrls } = useRelaySettings()
return (
<PrimaryPageLayout>
<NoteList />
<NoteList relayUrls={relayUrls} />
</PrimaryPageLayout>
)
}

View File

@@ -1,7 +1,9 @@
import NoteList from '@renderer/components/NoteList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
export default function HashtagPage({ hashtag }: { hashtag?: string }) {
const { relayUrls } = useRelaySettings()
if (!hashtag) {
return null
}
@@ -9,7 +11,11 @@ export default function HashtagPage({ hashtag }: { hashtag?: string }) {
return (
<SecondaryPageLayout titlebarContent={`# ${normalizedHashtag}`}>
<NoteList key={normalizedHashtag} filter={{ '#t': [normalizedHashtag] }} />
<NoteList
key={normalizedHashtag}
filter={{ '#t': [normalizedHashtag] }}
relayUrls={relayUrls}
/>
</SecondaryPageLayout>
)
}

View File

@@ -4,6 +4,7 @@ import ProfileAbout from '@renderer/components/ProfileAbout'
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Separator } from '@renderer/components/ui/separator'
import { useFetchProfile } from '@renderer/hooks'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { Copy } from 'lucide-react'
@@ -12,6 +13,7 @@ import { useEffect, useMemo, useState } from 'react'
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
const relayList = useFetchRelayList(pubkey)
const [copied, setCopied] = useState(false)
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
@@ -61,7 +63,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
</div>
</div>
<Separator className="my-4" />
<NoteList key={pubkey} filter={{ authors: [pubkey] }} />
<NoteList key={pubkey} filter={{ authors: [pubkey] }} relayUrls={relayList.write} />
</SecondaryPageLayout>
)
}

View File

@@ -1,13 +1,17 @@
import { TDraftEvent } from '@common/types'
import { createContext, useContext, useEffect, useState } from 'react'
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import client from '@renderer/services/client.service'
import { createContext, useContext, useEffect, useState } from 'react'
type TNostrContext = {
pubkey: string | null
canLogin: boolean
login: (nsec: string) => Promise<string>
logout: () => Promise<void>
publish: (draftEvent: TDraftEvent) => Promise<void>
/**
* Default publish the event to current relays, user's write relays and additional relays
*/
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<void>
}
const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -23,6 +27,7 @@ export const useNostr = () => {
export function NostrProvider({ children }: { children: React.ReactNode }) {
const [pubkey, setPubkey] = useState<string | null>(null)
const [canLogin, setCanLogin] = useState(false)
const relayList = useFetchRelayList(pubkey)
useEffect(() => {
window.api.nostr.getPublicKey().then((pubkey) => {
@@ -52,12 +57,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setPubkey(null)
}
const publish = async (draftEvent: TDraftEvent) => {
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
const event = await window.api.nostr.signEvent(draftEvent)
if (!event) {
throw new Error('sign event failed')
}
await client.publishEvent(event)
await client.publishEvent(relayList.write.concat(additionalRelayUrls), event)
}
return (

View File

@@ -48,7 +48,8 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
}, [pubkey])
const fetchNoteLikeCount = async (event: Event) => {
const events = await client.fetchEvents({
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read, {
'#e': [event.id],
kinds: [kinds.Reaction],
limit: 500
@@ -72,7 +73,8 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
}
const fetchNoteRepostCount = async (event: Event) => {
const events = await client.fetchEvents({
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read, {
'#e': [event.id],
kinds: [kinds.Repost],
limit: 100
@@ -92,7 +94,8 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
const fetchNoteLikedStatus = async (event: Event) => {
if (!pubkey) return false
const events = await client.fetchEvents({
const relayList = await client.fetchRelayList(pubkey)
const events = await client.fetchEvents(relayList.write, {
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Reaction]
@@ -119,7 +122,8 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
const fetchNoteRepostedStatus = async (event: Event) => {
if (!pubkey) return false
const events = await client.fetchEvents({
const relayList = await client.fetchRelayList(pubkey)
const events = await client.fetchEvents(relayList.write, {
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Repost]

View File

@@ -1,11 +1,13 @@
import { TRelayGroup } from '@common/types'
import { formatPubkey } from '@renderer/lib/pubkey'
import { TProfile } from '@renderer/types'
import { tagNameEquals } from '@renderer/lib/tag'
import { TProfile, TRelayList } from '@renderer/types'
import DataLoader from 'dataloader'
import { LRUCache } from 'lru-cache'
import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.service'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
const BIG_RELAY_URLS = [
'wss://relay.damus.io/',
@@ -24,24 +26,31 @@ class ClientService {
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000,
fetchMethod: async (filterStr) => {
const [event] = await this.fetchEvents(JSON.parse(filterStr))
const [event] = await this.fetchEvents(
BIG_RELAY_URLS.concat(this.relayUrls),
JSON.parse(filterStr)
)
return event
}
})
private eventDataloader = new DataLoader<string, NEvent | undefined>(
this.eventBatchLoadFn.bind(this),
{
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
}
)
private profileDataloader = new DataLoader<string, TProfile | undefined>(
this.profileBatchLoadFn.bind(this),
{
cacheMap: new LRUCache<string, Promise<TProfile | undefined>>({ max: 10000 })
}
)
private relayListDataLoader = new DataLoader<string, TRelayList>(
this.relayListBatchLoadFn.bind(this),
{
cacheMap: new LRUCache<string, Promise<TRelayList>>({ max: 10000 })
}
)
constructor() {
if (!ClientService.instance) {
@@ -68,9 +77,8 @@ class ClientService {
return this.pool.listConnectionStatus()
}
async publishEvent(event: NEvent) {
// TODO: outbox
return await Promise.any(this.pool.publish(this.relayUrls, event))
async publishEvent(relayUrls: string[], event: NEvent) {
return await Promise.any(this.pool.publish(this.relayUrls.concat(relayUrls), event))
}
subscribeEvents(
@@ -103,9 +111,10 @@ class ClientService {
})
}
async fetchEvents(filter: Filter, relayUrls: string[] = this.relayUrls) {
async fetchEvents(relayUrls: string[], filter: Filter) {
await this.initPromise
return await this.pool.querySync(relayUrls, filter)
// If relayUrls is empty, use this.relayUrls
return await this.pool.querySync(relayUrls.length > 0 ? relayUrls : this.relayUrls, filter)
}
async fetchEventByFilter(filter: Filter) {
@@ -120,8 +129,12 @@ class ClientService {
return this.profileDataloader.load(pubkey)
}
async fetchRelayList(pubkey: string): Promise<TRelayList> {
return this.relayListDataLoader.load(pubkey)
}
private async eventBatchLoadFn(ids: readonly string[]) {
const events = await this.fetchEvents({
const events = await this.fetchEvents(this.relayUrls, {
ids: ids as string[],
limit: ids.length
})
@@ -133,11 +146,11 @@ class ClientService {
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
},
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
}
)
for (const event of missingEvents) {
eventsMap.set(event.id, event)
@@ -148,7 +161,7 @@ class ClientService {
}
private async profileBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.fetchEvents({
const events = await this.fetchEvents(this.relayUrls, {
authors: pubkeys as string[],
kinds: [kinds.Metadata],
limit: pubkeys.length
@@ -165,12 +178,12 @@ 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
},
BIG_RELAY_URLS.filter((url) => !this.relayUrls.includes(url))
}
)
for (const event of missingEvents) {
const pubkey = event.pubkey
@@ -187,6 +200,49 @@ class ClientService {
})
}
private async relayListBatchLoadFn(pubkeys: readonly string[]) {
const events = await this.fetchEvents(BIG_RELAY_URLS.concat(this.relayUrls), {
authors: pubkeys as string[],
kinds: [kinds.RelayList],
limit: pubkeys.length
})
const eventsMap = new Map<string, NEvent>()
for (const event of events) {
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) => {
const event = eventsMap.get(pubkey)
const relayList = { write: [], read: [] } as TRelayList
if (!event) return relayList
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || !isWebsocketUrl(url)) return
const normalizedUrl = normalizeUrl(url)
switch (type) {
case 'w':
relayList.write.push(normalizedUrl)
break
case 'r':
relayList.read.push(normalizedUrl)
break
default:
relayList.write.push(normalizedUrl)
relayList.read.push(normalizedUrl)
}
})
return {
write: relayList.write.slice(0, 3),
read: relayList.read.slice(0, 3)
}
})
}
private parseProfileFromEvent(event: NEvent): TProfile {
try {
const profileObj = JSON.parse(event.content)

View File

@@ -6,3 +6,8 @@ export type TProfile = {
nip05?: string
about?: string
}
export type TRelayList = {
write: string[]
read: string[]
}