mirror of
https://github.com/dergigi/boris.git
synced 2025-12-22 17:14:20 +01:00
feat: add reading position sync across devices using Nostr Kind 30078
- Create readingPositionService.ts for save/load operations - Add syncReadingPosition setting (opt-in via Settings > Layout & Behavior) - Enhance useReadingPosition hook with auto-save (debounced 5s) and immediate save on navigation - Integrate position restore in ContentPanel with smooth scroll to saved position - Support both Nostr articles (naddr) and external URLs - Reading positions stored privately to user's relays - Auto-save excludes first 5% and last 5% of content to avoid noise - Position automatically restored when returning to article
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import ReactPlayer from 'react-player'
|
import ReactPlayer from 'react-player'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -36,6 +36,14 @@ import { classifyUrl } from '../utils/helpers'
|
|||||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import {
|
||||||
|
generateArticleIdentifier,
|
||||||
|
loadReadingPosition,
|
||||||
|
saveReadingPosition
|
||||||
|
} from '../services/readingPositionService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -129,10 +137,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get event store for reading position service
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
// Reading position tracking - only for text content, not videos
|
// Reading position tracking - only for text content, not videos
|
||||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
|
||||||
|
// Generate article identifier for saving/loading position
|
||||||
|
const articleIdentifier = useMemo(() => {
|
||||||
|
if (!selectedUrl) return null
|
||||||
|
return generateArticleIdentifier(selectedUrl)
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
// Callback to save reading position
|
||||||
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) return
|
||||||
|
if (!settings?.syncReadingPosition) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
|
await saveReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
factory,
|
||||||
|
articleIdentifier,
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save reading position:', error)
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||||
|
|
||||||
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
enabled: isTextContent,
|
enabled: isTextContent,
|
||||||
|
syncEnabled: settings?.syncReadingPosition,
|
||||||
|
onSave: handleSavePosition,
|
||||||
onReadingComplete: () => {
|
onReadingComplete: () => {
|
||||||
// Optional: Auto-mark as read when reading is complete
|
// Optional: Auto-mark as read when reading is complete
|
||||||
if (activeAccount && !isMarkedAsRead) {
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
@@ -141,6 +184,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Load saved reading position when article loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) return
|
||||||
|
if (!settings?.syncReadingPosition) return
|
||||||
|
|
||||||
|
const loadPosition = async () => {
|
||||||
|
try {
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
articleIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 0.95) {
|
||||||
|
// Wait for content to be fully rendered before scrolling
|
||||||
|
setTimeout(() => {
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('📖 Restored reading position:', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
}, 500) // Give content time to render
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosition()
|
||||||
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition])
|
||||||
|
|
||||||
|
// Save position before unmounting or changing article
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveNow) {
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [saveNow, selectedUrl])
|
||||||
|
|
||||||
// Close menu when clicking outside
|
// Close menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
paragraphAlignment: 'justify',
|
paragraphAlignment: 'justify',
|
||||||
|
syncReadingPosition: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
|||||||
@@ -104,6 +104,19 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
|
|||||||
<span>Auto-collapse sidebar on small screens</span>
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="syncReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.syncReadingPosition ?? false}
|
||||||
|
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Sync reading position across devices</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,68 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
interface UseReadingPositionOptions {
|
interface UseReadingPositionOptions {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
onPositionChange?: (position: number) => void
|
onPositionChange?: (position: number) => void
|
||||||
onReadingComplete?: () => void
|
onReadingComplete?: () => void
|
||||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadingPosition = ({
|
export const useReadingPosition = ({
|
||||||
enabled = true,
|
enabled = true,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
onReadingComplete,
|
onReadingComplete,
|
||||||
readingCompleteThreshold = 0.9
|
readingCompleteThreshold = 0.9,
|
||||||
|
syncEnabled = false,
|
||||||
|
onSave,
|
||||||
|
autoSaveInterval = 5000
|
||||||
}: UseReadingPositionOptions = {}) => {
|
}: UseReadingPositionOptions = {}) => {
|
||||||
const [position, setPosition] = useState(0)
|
const [position, setPosition] = useState(0)
|
||||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
const hasTriggeredComplete = useRef(false)
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
const lastSavedPosition = useRef(0)
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Debounced save function
|
||||||
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Don't save if position is too low (< 5%) or too high (> 95%)
|
||||||
|
if (currentPosition < 0.05 || currentPosition > 0.95) return
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1%)
|
||||||
|
if (Math.abs(currentPosition - lastSavedPosition.current) < 0.01) return
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new save
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, autoSaveInterval)
|
||||||
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
|
// Immediate save function
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Cancel any pending saves
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save if position is meaningful
|
||||||
|
if (position >= 0.05 && position <= 0.95) {
|
||||||
|
lastSavedPosition.current = position
|
||||||
|
onSave(position)
|
||||||
|
}
|
||||||
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
@@ -36,6 +83,9 @@ export const useReadingPosition = ({
|
|||||||
setPosition(clampedProgress)
|
setPosition(clampedProgress)
|
||||||
onPositionChange?.(clampedProgress)
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
|
// Schedule auto-save if sync is enabled
|
||||||
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
// Check if reading is complete
|
// Check if reading is complete
|
||||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
setIsReadingComplete(true)
|
setIsReadingComplete(true)
|
||||||
@@ -54,8 +104,13 @@ export const useReadingPosition = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener('resize', handleScroll)
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
|
// Clear save timer on unmount
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
}
|
}
|
||||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
}
|
||||||
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||||
|
|
||||||
// Reset reading complete state when enabled changes
|
// Reset reading complete state when enabled changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,6 +123,7 @@ export const useReadingPosition = ({
|
|||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
isReadingComplete,
|
isReadingComplete,
|
||||||
progressPercentage: Math.round(position * 100)
|
progressPercentage: Math.round(position * 100),
|
||||||
|
saveNow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/services/readingPositionService.ts
Normal file
184
src/services/readingPositionService.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { firstValueFrom } from 'rxjs'
|
||||||
|
import { publishEvent } from './writeService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
|
||||||
|
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
export interface ReadingPosition {
|
||||||
|
position: number // 0-1 scroll progress
|
||||||
|
timestamp: number // Unix timestamp
|
||||||
|
scrollTop?: number // Optional: pixel position
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract and parse reading position from an event
|
||||||
|
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||||
|
if (!event.content || event.content.length === 0) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(event.content) as ReadingPosition
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique identifier for an article
|
||||||
|
* For Nostr articles: use the naddr directly
|
||||||
|
* For external URLs: use base64url encoding of the URL
|
||||||
|
*/
|
||||||
|
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||||
|
// If it starts with "nostr:", extract the naddr
|
||||||
|
if (naddrOrUrl.startsWith('nostr:')) {
|
||||||
|
return naddrOrUrl.replace('nostr:', '')
|
||||||
|
}
|
||||||
|
// For URLs, use base64url encoding (URL-safe)
|
||||||
|
return btoa(naddrOrUrl)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save reading position to Nostr (Kind 30078)
|
||||||
|
*/
|
||||||
|
export async function saveReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
factory: EventFactory,
|
||||||
|
articleIdentifier: string,
|
||||||
|
position: ReadingPosition
|
||||||
|
): Promise<void> {
|
||||||
|
console.log('💾 Saving reading position:', {
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||||
|
position: position.position,
|
||||||
|
timestamp: position.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
const draft = await factory.create(async () => ({
|
||||||
|
kind: APP_DATA_KIND,
|
||||||
|
content: JSON.stringify(position),
|
||||||
|
tags: [
|
||||||
|
['d', dTag],
|
||||||
|
['client', 'boris']
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const signed = await factory.sign(draft)
|
||||||
|
|
||||||
|
// Use unified write service
|
||||||
|
await publishEvent(relayPool, eventStore, signed)
|
||||||
|
|
||||||
|
console.log('✅ Reading position saved successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load reading position from Nostr
|
||||||
|
*/
|
||||||
|
export async function loadReadingPosition(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
eventStore: IEventStore,
|
||||||
|
pubkey: string,
|
||||||
|
articleIdentifier: string
|
||||||
|
): Promise<ReadingPosition | null> {
|
||||||
|
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||||
|
|
||||||
|
console.log('📖 Loading reading position:', {
|
||||||
|
pubkey: pubkey.slice(0, 8) + '...',
|
||||||
|
identifier: articleIdentifier.slice(0, 32) + '...'
|
||||||
|
})
|
||||||
|
|
||||||
|
// First, check if we already have the position in the local event store
|
||||||
|
try {
|
||||||
|
const localEvent = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (localEvent) {
|
||||||
|
const content = getReadingPositionContent(localEvent)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ Reading position loaded from local store:', content.position)
|
||||||
|
|
||||||
|
// Still fetch from relays in the background to get any updates
|
||||||
|
relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('📭 No cached reading position found, fetching from relays...')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in local store, fetch from relays
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let hasResolved = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!hasResolved) {
|
||||||
|
console.log('⏱️ Reading position load timeout - no position found')
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}, 3000) // Shorter timeout for reading positions
|
||||||
|
|
||||||
|
const sub = relayPool
|
||||||
|
.subscription(RELAYS, {
|
||||||
|
kinds: [APP_DATA_KIND],
|
||||||
|
authors: [pubkey],
|
||||||
|
'#d': [dTag]
|
||||||
|
})
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe({
|
||||||
|
complete: async () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
try {
|
||||||
|
const event = await firstValueFrom(
|
||||||
|
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||||
|
)
|
||||||
|
if (event) {
|
||||||
|
const content = getReadingPositionContent(event)
|
||||||
|
if (content) {
|
||||||
|
console.log('✅ Reading position loaded from relays:', content.position)
|
||||||
|
resolve(content)
|
||||||
|
} else {
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📭 No reading position found')
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error loading reading position:', err)
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('❌ Reading position subscription error:', err)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!hasResolved) {
|
||||||
|
hasResolved = true
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sub.unsubscribe()
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ export interface UserSettings {
|
|||||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||||
// Reading settings
|
// Reading settings
|
||||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||||
|
// Reading position sync
|
||||||
|
syncReadingPosition?: boolean // default: false (opt-in)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettings(
|
export async function loadSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user