refactor: remove electron-related code
This commit is contained in:
98
src/providers/FollowListProvider.tsx
Normal file
98
src/providers/FollowListProvider.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { TDraftEvent } from '@/types'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
type TFollowListContext = {
|
||||
followListEvent: Event | undefined
|
||||
followings: string[]
|
||||
isReady: boolean
|
||||
follow: (pubkey: string) => Promise<void>
|
||||
unfollow: (pubkey: string) => Promise<void>
|
||||
}
|
||||
|
||||
const FollowListContext = createContext<TFollowListContext | undefined>(undefined)
|
||||
|
||||
export const useFollowList = () => {
|
||||
const context = useContext(FollowListContext)
|
||||
if (!context) {
|
||||
throw new Error('useFollowList must be used within a FollowListProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish } = useNostr()
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const followings = useMemo(
|
||||
() =>
|
||||
followListEvent?.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse() ?? [],
|
||||
[followListEvent]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
setIsReady(false)
|
||||
setFollowListEvent(undefined)
|
||||
const event = await client.fetchFollowListEvent(accountPubkey)
|
||||
setFollowListEvent(event)
|
||||
setIsReady(true)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [accountPubkey])
|
||||
|
||||
const follow = async (pubkey: string) => {
|
||||
if (!isReady || !accountPubkey) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
content: followListEvent?.content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags: (followListEvent?.tags ?? []).concat([['p', pubkey]])
|
||||
}
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
setFollowListEvent(newFollowListEvent)
|
||||
}
|
||||
|
||||
const unfollow = async (pubkey: string) => {
|
||||
if (!isReady || !accountPubkey || !followListEvent) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
content: followListEvent.content ?? '',
|
||||
created_at: dayjs().unix(),
|
||||
tags: followListEvent.tags.filter(
|
||||
([tagName, tagValue]) => tagName !== 'p' || tagValue !== pubkey
|
||||
)
|
||||
}
|
||||
const newFollowListEvent = await publish(newFollowListDraftEvent)
|
||||
client.updateFollowListCache(accountPubkey, newFollowListEvent)
|
||||
setFollowListEvent(newFollowListEvent)
|
||||
}
|
||||
|
||||
return (
|
||||
<FollowListContext.Provider
|
||||
value={{
|
||||
followListEvent,
|
||||
followings,
|
||||
isReady,
|
||||
follow,
|
||||
unfollow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FollowListContext.Provider>
|
||||
)
|
||||
}
|
||||
53
src/providers/NostrProvider/bunker.signer.ts
Normal file
53
src/providers/NostrProvider/bunker.signer.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ISigner, TDraftEvent } from '@/types'
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { generateSecretKey } from 'nostr-tools'
|
||||
import { BunkerSigner as NBunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
|
||||
|
||||
export class BunkerSigner implements ISigner {
|
||||
signer: NBunkerSigner | null = null
|
||||
private clientSecretKey: Uint8Array
|
||||
private pubkey: string | null = null
|
||||
|
||||
constructor(clientSecretKey?: string) {
|
||||
this.clientSecretKey = clientSecretKey ? hexToBytes(clientSecretKey) : generateSecretKey()
|
||||
}
|
||||
|
||||
async login(bunker: string): Promise<string> {
|
||||
const bunkerPointer = await parseBunkerInput(bunker)
|
||||
if (!bunkerPointer) {
|
||||
throw new Error('Invalid bunker')
|
||||
}
|
||||
|
||||
this.signer = new NBunkerSigner(this.clientSecretKey, bunkerPointer, {
|
||||
onauth: (url) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
})
|
||||
await this.signer.connect()
|
||||
return await this.signer.getPublicKey()
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
if (!this.pubkey) {
|
||||
this.pubkey = await this.signer.getPublicKey()
|
||||
}
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async signEvent(draftEvent: TDraftEvent) {
|
||||
if (!this.signer) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return this.signer.signEvent({
|
||||
...draftEvent,
|
||||
pubkey: await this.signer.getPublicKey()
|
||||
})
|
||||
}
|
||||
|
||||
getClientSecretKey() {
|
||||
return bytesToHex(this.clientSecretKey)
|
||||
}
|
||||
}
|
||||
216
src/providers/NostrProvider/index.tsx
Normal file
216
src/providers/NostrProvider/index.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { useToast } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { ISigner, TDraftEvent } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useRelaySettings } from '../RelaySettingsProvider'
|
||||
import { NsecSigner } from './nsec.signer'
|
||||
import { BunkerSigner } from './bunker.signer'
|
||||
import { Nip07Signer } from './nip-07.signer'
|
||||
|
||||
type TNostrContext = {
|
||||
pubkey: string | null
|
||||
setPubkey: (pubkey: string) => void
|
||||
nsecLogin: (nsec: string) => Promise<string>
|
||||
nip07Login: () => Promise<string>
|
||||
bunkerLogin: (bunker: string) => Promise<string>
|
||||
logout: () => void
|
||||
/**
|
||||
* Default publish the event to current relays, user's write relays and additional relays
|
||||
*/
|
||||
publish: (draftEvent: TDraftEvent, additionalRelayUrls?: string[]) => Promise<Event>
|
||||
signHttpAuth: (url: string, method: string) => Promise<string>
|
||||
signEvent: (draftEvent: TDraftEvent) => Promise<Event>
|
||||
checkLogin: <T>(cb?: () => T) => Promise<T | void>
|
||||
}
|
||||
|
||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||
|
||||
export const useNostr = () => {
|
||||
const context = useContext(NostrContext)
|
||||
if (!context) {
|
||||
throw new Error('useNostr must be used within a NostrProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
const { toast } = useToast()
|
||||
const [pubkey, setPubkey] = useState<string | null>(null)
|
||||
const [signer, setSigner] = useState<ISigner | null>(null)
|
||||
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
||||
const relayList = useFetchRelayList(pubkey)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const [account] = storage.getAccounts()
|
||||
if (!account) {
|
||||
if (!window.nostr) {
|
||||
return
|
||||
}
|
||||
|
||||
// For browser env, attempt to login with nip-07
|
||||
const nip07Signer = new Nip07Signer()
|
||||
const pubkey = await nip07Signer.getPublicKey()
|
||||
if (!pubkey) {
|
||||
return
|
||||
}
|
||||
storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
||||
return login(nip07Signer, pubkey)
|
||||
}
|
||||
|
||||
if (account.pubkey) {
|
||||
setPubkey(account.pubkey)
|
||||
}
|
||||
|
||||
// browser-nsec is deprecated
|
||||
if (account.signerType === 'browser-nsec') {
|
||||
if (account.nsec) {
|
||||
const browserNsecSigner = new NsecSigner()
|
||||
const pubkey = browserNsecSigner.login(account.nsec)
|
||||
storage.setAccounts([{ pubkey, signerType: 'nsec', nsec: account.nsec }])
|
||||
return login(browserNsecSigner, pubkey)
|
||||
}
|
||||
} else if (account.signerType === 'nsec') {
|
||||
if (account.nsec) {
|
||||
const browserNsecSigner = new NsecSigner()
|
||||
const pubkey = browserNsecSigner.login(account.nsec)
|
||||
return login(browserNsecSigner, pubkey)
|
||||
}
|
||||
} else if (account.signerType === 'nip-07') {
|
||||
const nip07Signer = new Nip07Signer()
|
||||
return login(nip07Signer, account.pubkey)
|
||||
} else if (account.signerType === 'bunker') {
|
||||
if (account.bunker && account.bunkerClientSecretKey) {
|
||||
const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey)
|
||||
const pubkey = await bunkerSigner.login(account.bunker)
|
||||
return login(bunkerSigner, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return logout()
|
||||
}
|
||||
init().catch(() => {
|
||||
logout()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = (signer: ISigner, pubkey: string) => {
|
||||
setPubkey(pubkey)
|
||||
setSigner(signer)
|
||||
return pubkey
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setPubkey(null)
|
||||
setSigner(null)
|
||||
storage.setAccounts([])
|
||||
}
|
||||
|
||||
const nsecLogin = async (nsec: string) => {
|
||||
const browserNsecSigner = new NsecSigner()
|
||||
const pubkey = browserNsecSigner.login(nsec)
|
||||
storage.setAccounts([{ pubkey, signerType: 'nsec', nsec }])
|
||||
return login(browserNsecSigner, pubkey)
|
||||
}
|
||||
|
||||
const nip07Login = async () => {
|
||||
try {
|
||||
const nip07Signer = new Nip07Signer()
|
||||
const pubkey = await nip07Signer.getPublicKey()
|
||||
if (!pubkey) {
|
||||
throw new Error('You did not allow to access your pubkey')
|
||||
}
|
||||
storage.setAccounts([{ pubkey, signerType: 'nip-07' }])
|
||||
return login(nip07Signer, pubkey)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Login failed',
|
||||
description: (err as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const bunkerLogin = async (bunker: string) => {
|
||||
const bunkerSigner = new BunkerSigner()
|
||||
const pubkey = await bunkerSigner.login(bunker)
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid bunker')
|
||||
}
|
||||
const bunkerUrl = new URL(bunker)
|
||||
bunkerUrl.searchParams.delete('secret')
|
||||
storage.setAccounts([
|
||||
{
|
||||
pubkey,
|
||||
signerType: 'bunker',
|
||||
bunker: bunkerUrl.toString(),
|
||||
bunkerClientSecretKey: bunkerSigner.getClientSecretKey()
|
||||
}
|
||||
])
|
||||
return login(bunkerSigner, pubkey)
|
||||
}
|
||||
|
||||
const signEvent = async (draftEvent: TDraftEvent) => {
|
||||
const event = await signer?.signEvent(draftEvent)
|
||||
if (!event) {
|
||||
throw new Error('sign event failed')
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
||||
const event = await signEvent(draftEvent)
|
||||
await client.publishEvent(
|
||||
relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls),
|
||||
event
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
const signHttpAuth = async (url: string, method: string) => {
|
||||
const event = await signEvent({
|
||||
content: '',
|
||||
kind: kinds.HTTPAuth,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method]
|
||||
]
|
||||
})
|
||||
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||
}
|
||||
|
||||
const checkLogin = async <T,>(cb?: () => T): Promise<T | void> => {
|
||||
if (signer) {
|
||||
return cb && cb()
|
||||
}
|
||||
return setOpenLoginDialog(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<NostrContext.Provider
|
||||
value={{
|
||||
pubkey,
|
||||
setPubkey,
|
||||
nsecLogin,
|
||||
nip07Login,
|
||||
bunkerLogin,
|
||||
logout,
|
||||
publish,
|
||||
signHttpAuth,
|
||||
checkLogin,
|
||||
signEvent
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<LoginDialog open={openLoginDialog} setOpen={setOpenLoginDialog} />
|
||||
</NostrContext.Provider>
|
||||
)
|
||||
}
|
||||
26
src/providers/NostrProvider/nip-07.signer.ts
Normal file
26
src/providers/NostrProvider/nip-07.signer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ISigner, TDraftEvent, TNip07 } from '@/types'
|
||||
|
||||
export class Nip07Signer implements ISigner {
|
||||
private signer: TNip07
|
||||
private pubkey: string | null = null
|
||||
|
||||
constructor() {
|
||||
if (!window.nostr) {
|
||||
throw new Error(
|
||||
'You need to install a nostr signer extension to login. Such as alby, nostr-keyx or nos2x.'
|
||||
)
|
||||
}
|
||||
this.signer = window.nostr
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
if (!this.pubkey) {
|
||||
this.pubkey = await this.signer.getPublicKey()
|
||||
}
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async signEvent(draftEvent: TDraftEvent) {
|
||||
return await this.signer.signEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
35
src/providers/NostrProvider/nsec.signer.ts
Normal file
35
src/providers/NostrProvider/nsec.signer.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ISigner, TDraftEvent } from '@/types'
|
||||
import { finalizeEvent, getPublicKey as nGetPublicKey, nip19 } from 'nostr-tools'
|
||||
|
||||
export class NsecSigner implements ISigner {
|
||||
private privkey: Uint8Array | null = null
|
||||
private pubkey: string | null = null
|
||||
|
||||
login(nsec: string) {
|
||||
const { type, data } = nip19.decode(nsec)
|
||||
if (type !== 'nsec') {
|
||||
throw new Error('invalid nsec')
|
||||
}
|
||||
|
||||
this.privkey = data
|
||||
this.pubkey = nGetPublicKey(data)
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async signEvent(draftEvent: TDraftEvent) {
|
||||
if (!this.privkey) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return finalizeEvent(draftEvent, this.privkey)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/providers/NoteStatsProvider.tsx
Normal file
199
src/providers/NoteStatsProvider.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
export type TNoteStats = {
|
||||
likeCount: number
|
||||
repostCount: number
|
||||
replyCount: number
|
||||
hasLiked: boolean
|
||||
hasReposted: boolean
|
||||
}
|
||||
|
||||
type TNoteStatsContext = {
|
||||
noteStatsMap: Map<string, Partial<TNoteStats>>
|
||||
updateNoteReplyCount: (noteId: string, replyCount: number) => void
|
||||
markNoteAsLiked: (noteId: string) => void
|
||||
markNoteAsReposted: (noteId: string) => void
|
||||
fetchNoteLikeCount: (event: Event) => Promise<number>
|
||||
fetchNoteRepostCount: (event: Event) => Promise<number>
|
||||
fetchNoteLikedStatus: (event: Event) => Promise<boolean>
|
||||
fetchNoteRepostedStatus: (event: Event) => Promise<boolean>
|
||||
}
|
||||
|
||||
const NoteStatsContext = createContext<TNoteStatsContext | undefined>(undefined)
|
||||
|
||||
export const useNoteStats = () => {
|
||||
const context = useContext(NoteStatsContext)
|
||||
if (!context) {
|
||||
throw new Error('useNoteStats must be used within a NoteStatsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [noteStatsMap, setNoteStatsMap] = useState<Map<string, Partial<TNoteStats>>>(new Map())
|
||||
const { pubkey } = useNostr()
|
||||
|
||||
useEffect(() => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map()
|
||||
for (const [noteId, stats] of prev) {
|
||||
newMap.set(noteId, { ...stats, hasLiked: undefined, hasReposted: undefined })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
}, [pubkey])
|
||||
|
||||
const fetchNoteLikeCount = async (event: Event) => {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Reaction],
|
||||
limit: 500
|
||||
})
|
||||
const countMap = new Map<string, number>()
|
||||
for (const e of events) {
|
||||
const targetEventId = e.tags.findLast(tagNameEquals('e'))?.[1]
|
||||
if (targetEventId) {
|
||||
countMap.set(targetEventId, (countMap.get(targetEventId) || 0) + 1)
|
||||
}
|
||||
}
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
for (const [eventId, count] of countMap) {
|
||||
const old = prev.get(eventId)
|
||||
newMap.set(
|
||||
eventId,
|
||||
old ? { ...old, likeCount: Math.max(count, old.likeCount ?? 0) } : { likeCount: count }
|
||||
)
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return countMap.get(event.id) || 0
|
||||
}
|
||||
|
||||
const fetchNoteRepostCount = async (event: Event) => {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const events = await client.fetchEvents(relayList.read.slice(0, 3), {
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.Repost],
|
||||
limit: 100
|
||||
})
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(
|
||||
event.id,
|
||||
old
|
||||
? { ...old, repostCount: Math.max(events.length, old.repostCount ?? 0) }
|
||||
: { repostCount: events.length }
|
||||
)
|
||||
return newMap
|
||||
})
|
||||
return events.length
|
||||
}
|
||||
|
||||
const fetchNoteLikedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Reaction]
|
||||
})
|
||||
const likedEventIds = events
|
||||
.map((e) => e.tags.findLast(tagNameEquals('e'))?.[1])
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
likedEventIds.forEach((eventId) => {
|
||||
const old = newMap.get(eventId)
|
||||
newMap.set(eventId, old ? { ...old, hasLiked: true } : { hasLiked: true })
|
||||
})
|
||||
if (!likedEventIds.includes(event.id)) {
|
||||
const old = newMap.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasLiked: false } : { hasLiked: false })
|
||||
}
|
||||
return newMap
|
||||
})
|
||||
return likedEventIds.includes(event.id)
|
||||
}
|
||||
|
||||
const fetchNoteRepostedStatus = async (event: Event) => {
|
||||
if (!pubkey) return false
|
||||
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const events = await client.fetchEvents(relayList.write, {
|
||||
'#e': [event.id],
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Repost]
|
||||
})
|
||||
|
||||
setNoteStatsMap((prev) => {
|
||||
const hasReposted = events.length > 0
|
||||
const newMap = new Map(prev)
|
||||
const old = prev.get(event.id)
|
||||
newMap.set(event.id, old ? { ...old, hasReposted } : { hasReposted })
|
||||
return newMap
|
||||
})
|
||||
return events.length > 0
|
||||
}
|
||||
|
||||
const updateNoteReplyCount = (noteId: string, replyCount: number) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
if (!old) {
|
||||
return new Map(prev).set(noteId, { replyCount })
|
||||
} else if (old.replyCount === undefined || old.replyCount < replyCount) {
|
||||
return new Map(prev).set(noteId, { ...old, replyCount })
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsLiked = (noteId: string) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasLiked: true, likeCount: (old.likeCount ?? 0) + 1 }
|
||||
: { hasLiked: true, likeCount: 1 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const markNoteAsReposted = (noteId: string) => {
|
||||
setNoteStatsMap((prev) => {
|
||||
const old = prev.get(noteId)
|
||||
return new Map(prev).set(
|
||||
noteId,
|
||||
old
|
||||
? { ...old, hasReposted: true, repostCount: (old.repostCount ?? 0) + 1 }
|
||||
: { hasReposted: true, repostCount: 1 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteStatsContext.Provider
|
||||
value={{
|
||||
noteStatsMap,
|
||||
fetchNoteLikeCount,
|
||||
fetchNoteLikedStatus,
|
||||
fetchNoteRepostCount,
|
||||
fetchNoteRepostedStatus,
|
||||
updateNoteReplyCount,
|
||||
markNoteAsLiked,
|
||||
markNoteAsReposted
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NoteStatsContext.Provider>
|
||||
)
|
||||
}
|
||||
171
src/providers/RelaySettingsProvider.tsx
Normal file
171
src/providers/RelaySettingsProvider.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { TRelayGroup } from '@/types'
|
||||
import { checkAlgoRelay, checkSearchRelay } from '@/lib/relay'
|
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type TRelaySettingsContext = {
|
||||
relayGroups: TRelayGroup[]
|
||||
temporaryRelayUrls: string[]
|
||||
relayUrls: string[]
|
||||
searchableRelayUrls: string[]
|
||||
areAlgoRelays: boolean
|
||||
switchRelayGroup: (groupName: string) => void
|
||||
renameRelayGroup: (oldGroupName: string, newGroupName: string) => string | null
|
||||
deleteRelayGroup: (groupName: string) => void
|
||||
addRelayGroup: (groupName: string, relayUrls?: string[]) => string | null
|
||||
updateRelayGroupRelayUrls: (groupName: string, relayUrls: string[]) => void
|
||||
setTemporaryRelayUrls: Dispatch<string[]>
|
||||
}
|
||||
|
||||
const RelaySettingsContext = createContext<TRelaySettingsContext | undefined>(undefined)
|
||||
|
||||
export const useRelaySettings = () => {
|
||||
const context = useContext(RelaySettingsContext)
|
||||
if (!context) {
|
||||
throw new Error('useRelaySettings must be used within a RelaySettingsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
|
||||
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
||||
const [relayUrls, setRelayUrls] = useState<string[]>(
|
||||
temporaryRelayUrls.length
|
||||
? temporaryRelayUrls
|
||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
||||
)
|
||||
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>([])
|
||||
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const tempRelays = searchParams
|
||||
.getAll('r')
|
||||
.filter((url) => isWebsocketUrl(url))
|
||||
.map((url) => normalizeUrl(url))
|
||||
if (tempRelays.length) {
|
||||
setTemporaryRelayUrls(tempRelays)
|
||||
}
|
||||
const storedGroups = await storage.getRelayGroups()
|
||||
setRelayGroups(storedGroups)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async () => {
|
||||
const newRelayUrls = temporaryRelayUrls.length
|
||||
? temporaryRelayUrls
|
||||
: (relayGroups.find((group) => group.isActive)?.relayUrls ?? [])
|
||||
|
||||
const relayInfos = await client.fetchRelayInfos(newRelayUrls)
|
||||
setSearchableRelayUrls(newRelayUrls.filter((_, index) => checkSearchRelay(relayInfos[index])))
|
||||
const nonAlgoRelayUrls = newRelayUrls.filter((_, index) => !checkAlgoRelay(relayInfos[index]))
|
||||
setAreAlgoRelays(newRelayUrls.length > 0 && nonAlgoRelayUrls.length === 0)
|
||||
client.setCurrentRelayUrls(nonAlgoRelayUrls)
|
||||
if (JSON.stringify(relayUrls) !== JSON.stringify(newRelayUrls)) {
|
||||
setRelayUrls(newRelayUrls)
|
||||
}
|
||||
}
|
||||
handler()
|
||||
}, [relayGroups, temporaryRelayUrls, relayUrls])
|
||||
|
||||
const updateGroups = async (fn: (pre: TRelayGroup[]) => TRelayGroup[]) => {
|
||||
let newGroups = relayGroups
|
||||
setRelayGroups((pre) => {
|
||||
newGroups = fn(pre)
|
||||
return newGroups
|
||||
})
|
||||
await storage.setRelayGroups(newGroups)
|
||||
}
|
||||
|
||||
const switchRelayGroup = (groupName: string) => {
|
||||
updateGroups((pre) =>
|
||||
pre.map((group) => ({
|
||||
...group,
|
||||
isActive: group.groupName === groupName
|
||||
}))
|
||||
)
|
||||
setTemporaryRelayUrls([])
|
||||
}
|
||||
|
||||
const deleteRelayGroup = (groupName: string) => {
|
||||
updateGroups((pre) => pre.filter((group) => group.groupName !== groupName))
|
||||
}
|
||||
|
||||
const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => {
|
||||
updateGroups((pre) =>
|
||||
pre.map((group) => ({
|
||||
...group,
|
||||
relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const renameRelayGroup = (oldGroupName: string, newGroupName: string) => {
|
||||
if (newGroupName === '') {
|
||||
return null
|
||||
}
|
||||
if (oldGroupName === newGroupName) {
|
||||
return null
|
||||
}
|
||||
updateGroups((pre) => {
|
||||
if (pre.some((group) => group.groupName === newGroupName)) {
|
||||
return pre
|
||||
}
|
||||
return pre.map((group) => ({
|
||||
...group,
|
||||
groupName: group.groupName === oldGroupName ? newGroupName : group.groupName
|
||||
}))
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const addRelayGroup = (groupName: string, relayUrls: string[] = []) => {
|
||||
if (groupName === '') {
|
||||
return null
|
||||
}
|
||||
const normalizedUrls = relayUrls
|
||||
.filter((url) => isWebsocketUrl(url))
|
||||
.map((url) => normalizeUrl(url))
|
||||
updateGroups((pre) => {
|
||||
if (pre.some((group) => group.groupName === groupName)) {
|
||||
return pre
|
||||
}
|
||||
return [
|
||||
...pre,
|
||||
{
|
||||
groupName,
|
||||
relayUrls: normalizedUrls,
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RelaySettingsContext.Provider
|
||||
value={{
|
||||
relayGroups,
|
||||
temporaryRelayUrls,
|
||||
relayUrls,
|
||||
searchableRelayUrls,
|
||||
areAlgoRelays,
|
||||
switchRelayGroup,
|
||||
renameRelayGroup,
|
||||
deleteRelayGroup,
|
||||
addRelayGroup,
|
||||
updateRelayGroupRelayUrls,
|
||||
setTemporaryRelayUrls
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RelaySettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
56
src/providers/ScreenSizeProvider.tsx
Normal file
56
src/providers/ScreenSizeProvider.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type TScreenSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
type TScreenSizeContext = {
|
||||
screenSize: TScreenSize
|
||||
isSmallScreen: boolean
|
||||
}
|
||||
|
||||
const ScreenSizeContext = createContext<TScreenSizeContext | undefined>(undefined)
|
||||
|
||||
export const useScreenSize = () => {
|
||||
const context = useContext(ScreenSizeContext)
|
||||
if (!context) {
|
||||
throw new Error('useScreenSize must be used within a ScreenSizeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ScreenSizeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [screenSize, setScreenSize] = useState<TScreenSize>('xl')
|
||||
const isSmallScreen = screenSize === 'sm'
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
setScreenSize('sm')
|
||||
} else if (window.innerWidth < 768) {
|
||||
setScreenSize('md')
|
||||
} else if (window.innerWidth < 1024) {
|
||||
setScreenSize('lg')
|
||||
} else if (window.innerWidth < 1280) {
|
||||
setScreenSize('xl')
|
||||
} else {
|
||||
setScreenSize('2xl')
|
||||
}
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ScreenSizeContext.Provider
|
||||
value={{
|
||||
screenSize,
|
||||
isSmallScreen
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScreenSizeContext.Provider>
|
||||
)
|
||||
}
|
||||
91
src/providers/ThemeProvider.tsx
Normal file
91
src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import storage from '@/services/storage.service'
|
||||
import { TTheme, TThemeSetting } from '@/types'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: TTheme
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
themeSetting: TThemeSetting
|
||||
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
|
||||
}
|
||||
|
||||
async function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const [themeSetting, setThemeSetting] = useState<TThemeSetting>(
|
||||
(localStorage.getItem('themeSetting') as TThemeSetting | null) ?? 'system'
|
||||
)
|
||||
const [theme, setTheme] = useState<TTheme>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const themeSetting = await storage.getThemeSetting()
|
||||
if (themeSetting === 'system') {
|
||||
setTheme(await getSystemTheme())
|
||||
return
|
||||
}
|
||||
setTheme(themeSetting)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (themeSetting !== 'system') return
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setTheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
setTheme(mediaQuery.matches ? 'dark' : 'light')
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [themeSetting])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = async () => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
updateTheme()
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
themeSetting: themeSetting,
|
||||
setThemeSetting: async (themeSetting: TThemeSetting) => {
|
||||
await storage.setThemeSetting(themeSetting)
|
||||
setThemeSetting(themeSetting)
|
||||
if (themeSetting === 'system') {
|
||||
setTheme(await getSystemTheme())
|
||||
return
|
||||
}
|
||||
setTheme(themeSetting)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user