feat: hide replies from untrusted users
This commit is contained in:
@@ -15,6 +15,7 @@ import { NostrProvider } from './providers/NostrProvider'
|
|||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
import { ReplyProvider } from './providers/ReplyProvider'
|
import { ReplyProvider } from './providers/ReplyProvider'
|
||||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||||
|
import { UserTrustProvider } from './providers/UserTrustProvider'
|
||||||
import { ZapProvider } from './providers/ZapProvider'
|
import { ZapProvider } from './providers/ZapProvider'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
@@ -27,6 +28,7 @@ export default function App(): JSX.Element {
|
|||||||
<FavoriteRelaysProvider>
|
<FavoriteRelaysProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
|
<UserTrustProvider>
|
||||||
<BookmarksProvider>
|
<BookmarksProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<ReplyProvider>
|
<ReplyProvider>
|
||||||
@@ -39,6 +41,7 @@ export default function App(): JSX.Element {
|
|||||||
</ReplyProvider>
|
</ReplyProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</BookmarksProvider>
|
</BookmarksProvider>
|
||||||
|
</UserTrustProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
|||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Collapsible from '../Collapsible'
|
||||||
import Content from '../Content'
|
import Content from '../Content'
|
||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import NoteOptions from '../NoteOptions'
|
import NoteOptions from '../NoteOptions'
|
||||||
@@ -12,7 +13,6 @@ import NoteStats from '../NoteStats'
|
|||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
import Collapsible from '../Collapsible'
|
|
||||||
|
|
||||||
export default function ReplyNote({
|
export default function ReplyNote({
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { generateEventIdFromETag, tagNameEquals } from '@/lib/tag'
|
import { generateEventIdFromETag, tagNameEquals } from '@/lib/tag'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
import { useReply } from '@/providers/ReplyProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -31,6 +32,7 @@ export default function ReplyNoteList({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { currentIndex } = useSecondaryPage()
|
const { currentIndex } = useSecondaryPage()
|
||||||
|
const { isUserTrusted } = useUserTrust()
|
||||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
||||||
const { repliesMap, addReplies } = useReply()
|
const { repliesMap, addReplies } = useReply()
|
||||||
const replies = useMemo(() => {
|
const replies = useMemo(() => {
|
||||||
@@ -248,6 +250,10 @@ export default function ReplyNoteList({
|
|||||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{replies.slice(0, showCount).map((reply) => {
|
{replies.slice(0, showCount).map((reply) => {
|
||||||
|
if (!isUserTrusted(reply.pubkey)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const parentEventTag = getParentEventTag(reply)
|
const parentEventTag = getParentEventTag(reply)
|
||||||
const parentEventOriginalId = parentEventTag?.[1]
|
const parentEventOriginalId = parentEventTag?.[1]
|
||||||
const parentEventId = parentEventTag ? generateEventIdFromETag(parentEventTag) : undefined
|
const parentEventId = parentEventTag ? generateEventIdFromETag(parentEventTag) : undefined
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const StorageKey = {
|
|||||||
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
|
||||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService',
|
||||||
AUTOPLAY: 'autoplay',
|
AUTOPLAY: 'autoplay',
|
||||||
|
HIDE_UNTRUSTED_REPLIES: 'hideUntrustedReplies',
|
||||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||||
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
ACCOUNT_FOLLOW_LIST_EVENT_MAP: 'accountFollowListEventMap', // deprecated
|
||||||
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
|
ACCOUNT_MUTE_LIST_EVENT_MAP: 'accountMuteListEventMap', // deprecated
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAutoplay } from '@/providers/AutoplayProvider'
|
import { useAutoplay } from '@/providers/AutoplayProvider'
|
||||||
import { useTheme } from '@/providers/ThemeProvider'
|
import { useTheme } from '@/providers/ThemeProvider'
|
||||||
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { SelectValue } from '@radix-ui/react-select'
|
import { SelectValue } from '@radix-ui/react-select'
|
||||||
import { forwardRef, HTMLProps, useState } from 'react'
|
import { forwardRef, HTMLProps, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -15,6 +16,8 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
const { themeSetting, setThemeSetting } = useTheme()
|
const { themeSetting, setThemeSetting } = useTheme()
|
||||||
const { autoplay, setAutoplay } = useAutoplay()
|
const { autoplay, setAutoplay } = useAutoplay()
|
||||||
|
const { enabled: hideUntrustedRepliesEnabled, updateEnabled: updateHideUntrustedRepliesEnabled } =
|
||||||
|
useUserTrust()
|
||||||
|
|
||||||
const handleLanguageChange = (value: TLanguage) => {
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
i18n.changeLanguage(value)
|
i18n.changeLanguage(value)
|
||||||
@@ -63,6 +66,19 @@ const GeneralSettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
|
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="hide-untrusted-replies" className="text-base font-normal">
|
||||||
|
{t('Hide replies from untrusted users')}
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{t('Only show replies from your followed users and the users they follow')}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="hide-untrusted-replies"
|
||||||
|
checked={hideUntrustedRepliesEnabled}
|
||||||
|
onCheckedChange={updateHideUntrustedRepliesEnabled}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
59
src/providers/UserTrustProvider.tsx
Normal file
59
src/providers/UserTrustProvider.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import client from '@/services/client.service'
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
|
|
||||||
|
type TUserTrustContext = {
|
||||||
|
enabled: boolean
|
||||||
|
updateEnabled: (enabled: boolean) => void
|
||||||
|
isUserTrusted: (pubkey: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useUserTrust = () => {
|
||||||
|
const context = useContext(UserTrustContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useUserTrust must be used within a UserTrustProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const wotSet = new Set<string>()
|
||||||
|
|
||||||
|
export function UserTrustProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { pubkey: currentPubkey } = useNostr()
|
||||||
|
const [enabled, setEnabled] = useState(storage.getHideUntrustedReplies())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPubkey) return
|
||||||
|
|
||||||
|
const initWoT = async () => {
|
||||||
|
const followings = await client.fetchFollowings(currentPubkey)
|
||||||
|
await Promise.allSettled(
|
||||||
|
followings.map(async (pubkey) => {
|
||||||
|
wotSet.add(pubkey)
|
||||||
|
const _followings = await client.fetchFollowings(pubkey)
|
||||||
|
_followings.forEach((following) => wotSet.add(following))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
initWoT()
|
||||||
|
}, [currentPubkey])
|
||||||
|
|
||||||
|
const updateEnabled = (enabled: boolean) => {
|
||||||
|
setEnabled(enabled)
|
||||||
|
storage.setHideUntrustedReplies(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUserTrusted = (pubkey: string) => {
|
||||||
|
if (!currentPubkey || !enabled) return true
|
||||||
|
return wotSet.has(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserTrustContext.Provider value={{ enabled, updateEnabled, isUserTrusted }}>
|
||||||
|
{children}
|
||||||
|
</UserTrustContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ class ClientService extends EventTarget {
|
|||||||
maxBatchSize: 500
|
maxBatchSize: 500
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
private fetchFollowListEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
|
this.followListEventBatchLoadFn.bind(this),
|
||||||
|
{
|
||||||
|
batchScheduleFn: (callback) => setTimeout(callback, 50),
|
||||||
|
maxBatchSize: 500
|
||||||
|
}
|
||||||
|
)
|
||||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.relayListEventBatchLoadFn.bind(this),
|
this.relayListEventBatchLoadFn.bind(this),
|
||||||
{
|
{
|
||||||
@@ -659,7 +666,6 @@ class ClientService extends EventTarget {
|
|||||||
const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey)
|
const profileFromBigRelays = await this.fetchProfileEventFromBigRelaysDataloader.load(pubkey)
|
||||||
if (profileFromBigRelays) {
|
if (profileFromBigRelays) {
|
||||||
this.addUsernameToIndex(profileFromBigRelays)
|
this.addUsernameToIndex(profileFromBigRelays)
|
||||||
await indexedDb.putReplaceableEvent(profileFromBigRelays)
|
|
||||||
return profileFromBigRelays
|
return profileFromBigRelays
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,12 +761,8 @@ class ClientService extends EventTarget {
|
|||||||
await this.relayListEventBatchLoadFn([pubkey])
|
await this.relayListEventBatchLoadFn([pubkey])
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
|
async fetchFollowListEvent(pubkey: string) {
|
||||||
const event = await this.followListCache.fetch(pubkey)
|
return await this.followListCache.fetch(pubkey)
|
||||||
if (storeToIndexedDb && event) {
|
|
||||||
await indexedDb.putReplaceableEvent(event)
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchBookmarkListEvent(pubkey: string): Promise<NEvent | undefined> {
|
async fetchBookmarkListEvent(pubkey: string): Promise<NEvent | undefined> {
|
||||||
@@ -778,8 +780,8 @@ class ClientService extends EventTarget {
|
|||||||
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchFollowings(pubkey: string, storeToIndexedDb = false) {
|
async fetchFollowings(pubkey: string) {
|
||||||
const followListEvent = await this.fetchFollowListEvent(pubkey, storeToIndexedDb)
|
const followListEvent = await this.fetchFollowListEvent(pubkey)
|
||||||
return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []
|
return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,11 +828,13 @@ class ClientService extends EventTarget {
|
|||||||
|
|
||||||
updateFollowListCache(event: NEvent) {
|
updateFollowListCache(event: NEvent) {
|
||||||
this.followListCache.set(event.pubkey, Promise.resolve(event))
|
this.followListCache.set(event.pubkey, Promise.resolve(event))
|
||||||
|
indexedDb.putReplaceableEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRelayListCache(event: NEvent) {
|
updateRelayListCache(event: NEvent) {
|
||||||
this.relayListEventDataLoader.clear(event.pubkey)
|
this.relayListEventDataLoader.clear(event.pubkey)
|
||||||
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
||||||
|
indexedDb.putReplaceableEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchNpubsFromCache(query: string, limit: number = 100) {
|
async searchNpubsFromCache(query: string, limit: number = 100) {
|
||||||
@@ -845,7 +849,7 @@ class ClientService extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
|
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
|
||||||
const followings = await this.fetchFollowings(pubkey, true)
|
const followings = await this.fetchFollowings(pubkey)
|
||||||
for (let i = 0; i * 20 < followings.length; i++) {
|
for (let i = 0; i * 20 < followings.length; i++) {
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -1016,6 +1020,30 @@ class ClientService extends EventTarget {
|
|||||||
return profileEvents
|
return profileEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async followListEventBatchLoadFn(pubkeys: readonly string[]) {
|
||||||
|
const events = await this.query(BIG_RELAY_URLS, {
|
||||||
|
authors: Array.from(new Set(pubkeys)),
|
||||||
|
kinds: [kinds.Contacts],
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const followListEvents = pubkeys.map((pubkey) => {
|
||||||
|
return eventsMap.get(pubkey)
|
||||||
|
})
|
||||||
|
|
||||||
|
followListEvents.forEach(
|
||||||
|
(followListEvent) => followListEvent && indexedDb.putReplaceableEvent(followListEvent)
|
||||||
|
)
|
||||||
|
return followListEvents
|
||||||
|
}
|
||||||
|
|
||||||
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
|
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
|
||||||
const events = await this.query(BIG_RELAY_URLS, {
|
const events = await this.query(BIG_RELAY_URLS, {
|
||||||
authors: pubkeys as string[],
|
authors: pubkeys as string[],
|
||||||
@@ -1047,9 +1075,19 @@ class ClientService extends EventTarget {
|
|||||||
if (storedFollowListEvent) {
|
if (storedFollowListEvent) {
|
||||||
return storedFollowListEvent
|
return storedFollowListEvent
|
||||||
}
|
}
|
||||||
|
const followListEventFromBigRelays =
|
||||||
|
await this.fetchFollowListEventFromBigRelaysDataloader.load(pubkey)
|
||||||
|
if (followListEventFromBigRelays) {
|
||||||
|
return followListEventFromBigRelays
|
||||||
|
}
|
||||||
|
|
||||||
const relayList = await this.fetchRelayList(pubkey)
|
const relayList = await this.fetchRelayList(pubkey)
|
||||||
const followListEvents = await this.query(relayList.write.concat(BIG_RELAY_URLS), {
|
const relays = relayList.write.filter((url) => !BIG_RELAY_URLS.includes(url))
|
||||||
|
if (!relays.length) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const followListEvents = await this.query(relays, {
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
kinds: [kinds.Contacts]
|
kinds: [kinds.Contacts]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class LocalStorageService {
|
|||||||
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
||||||
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
|
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
|
||||||
private autoplay: boolean = true
|
private autoplay: boolean = true
|
||||||
|
private hideUntrustedReplies: boolean = true
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -92,6 +93,9 @@ class LocalStorageService {
|
|||||||
|
|
||||||
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
|
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
|
||||||
|
|
||||||
|
this.hideUntrustedReplies =
|
||||||
|
window.localStorage.getItem(StorageKey.HIDE_UNTRUSTED_REPLIES) !== 'false'
|
||||||
|
|
||||||
// Clean up deprecated data
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||||
@@ -247,6 +251,14 @@ class LocalStorageService {
|
|||||||
this.autoplay = autoplay
|
this.autoplay = autoplay
|
||||||
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
|
window.localStorage.setItem(StorageKey.AUTOPLAY, autoplay.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHideUntrustedReplies() {
|
||||||
|
return this.hideUntrustedReplies
|
||||||
|
}
|
||||||
|
setHideUntrustedReplies(hide: boolean) {
|
||||||
|
this.hideUntrustedReplies = hide
|
||||||
|
window.localStorage.setItem(StorageKey.HIDE_UNTRUSTED_REPLIES, hide.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = new LocalStorageService()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
Reference in New Issue
Block a user