refactor: remove electron-related code

This commit is contained in:
codytseng
2024-12-21 23:20:30 +08:00
parent bed8df06e8
commit 2b1e6fe8f5
200 changed files with 2771 additions and 8432 deletions

View 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>
)
}

View 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)
}
}

View 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>
)
}

View 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)
}
}

View 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
}
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}