287 lines
No EOL
7.8 KiB
TypeScript
287 lines
No EOL
7.8 KiB
TypeScript
/**
|
|
* useAutosaveWithFlagging - Debounced autosave with content flagging
|
|
*
|
|
* Combines content flagging with autosave, showing toast notifications
|
|
* only when save state changes (not on every keystroke).
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { useContentFlagging, type UseContentFlaggingOptions } from './useContentFlagging.js'
|
|
|
|
import type { ContentFlagResult } from './types.js'
|
|
|
|
export type AutosaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'blocked'
|
|
|
|
export interface AutosaveToastConfig {
|
|
/** Show toast on successful save */
|
|
onSave?: boolean
|
|
/** Show toast on save error */
|
|
onError?: boolean
|
|
/** Show toast when content is blocked by flagging */
|
|
onBlocked?: boolean
|
|
/** Minimum time between toasts (prevents spam) */
|
|
debounceMs?: number
|
|
}
|
|
|
|
export interface UseAutosaveWithFlaggingOptions extends Omit<UseContentFlaggingOptions, 'onFlagViolation' | 'onPass'> {
|
|
/** The save function */
|
|
onSave: (value: string) => Promise<void>
|
|
/** Debounce delay before autosave (ms) */
|
|
autosaveDelayMs?: number
|
|
/** Toast notification callbacks */
|
|
toast?: {
|
|
success?: (message: string) => void
|
|
error?: (message: string) => void
|
|
warning?: (message: string) => void
|
|
loading?: (message: string) => string // Returns toast ID for update
|
|
update?: (id: string, message: string, type: 'success' | 'error') => void
|
|
dismiss?: (id: string) => void
|
|
}
|
|
/** Toast configuration */
|
|
toastConfig?: AutosaveToastConfig
|
|
/** Whether autosave is enabled */
|
|
autosaveEnabled?: boolean
|
|
}
|
|
|
|
export interface UseAutosaveWithFlaggingReturn {
|
|
/** Current flag result */
|
|
flagResult: ContentFlagResult | null
|
|
/** Whether content passes flagging */
|
|
passes: boolean
|
|
/** Current flag score */
|
|
score: number
|
|
/** Current autosave status */
|
|
status: AutosaveStatus
|
|
/** Whether content is being analyzed */
|
|
isAnalyzing: boolean
|
|
/** Whether save is in progress */
|
|
isSaving: boolean
|
|
/** Last error message */
|
|
error: string | null
|
|
/** Manually trigger save */
|
|
save: () => Promise<void>
|
|
/** Reset status to idle */
|
|
reset: () => void
|
|
}
|
|
|
|
const DEFAULT_TOAST_CONFIG: AutosaveToastConfig = {
|
|
onSave: true,
|
|
onError: true,
|
|
onBlocked: true,
|
|
debounceMs: 2000,
|
|
}
|
|
|
|
/**
|
|
* Hook combining content flagging with debounced autosave and toast notifications
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* const { showToast, updateToast } = useToast()
|
|
*
|
|
* const { passes, status, score } = useAutosaveWithFlagging(bio, {
|
|
* threshold: 40,
|
|
* context: 'bio',
|
|
* autosaveDelayMs: 2000,
|
|
* onSave: async (value) => {
|
|
* await api.updateProfile({ bio: value })
|
|
* },
|
|
* toast: {
|
|
* success: (msg) => showToast(msg, 'success'),
|
|
* error: (msg) => showToast(msg, 'error'),
|
|
* warning: (msg) => showToast(msg, 'warning'),
|
|
* loading: (msg) => showToast(msg, 'loading'),
|
|
* update: (id, msg, type) => updateToast(id, msg, type),
|
|
* },
|
|
* })
|
|
* ```
|
|
*/
|
|
export function useAutosaveWithFlagging(
|
|
value: string,
|
|
options: UseAutosaveWithFlaggingOptions
|
|
): UseAutosaveWithFlaggingReturn {
|
|
const {
|
|
onSave,
|
|
autosaveDelayMs = 2000,
|
|
toast,
|
|
toastConfig = DEFAULT_TOAST_CONFIG,
|
|
autosaveEnabled = true,
|
|
// Flagging options
|
|
threshold,
|
|
debounceMs,
|
|
enabled,
|
|
context,
|
|
enabledCategories,
|
|
categoryWeights,
|
|
enableSentiment,
|
|
whitelist,
|
|
customWordLists,
|
|
} = options
|
|
|
|
const [status, setStatus] = useState<AutosaveStatus>('idle')
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Refs for debouncing
|
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const lastSavedValueRef = useRef<string>(value)
|
|
const lastToastTimeRef = useRef<number>(0)
|
|
const loadingToastIdRef = useRef<string | null>(null)
|
|
|
|
// Content flagging
|
|
const flagging = useContentFlagging(value, {
|
|
threshold,
|
|
debounceMs,
|
|
enabled,
|
|
context,
|
|
enabledCategories,
|
|
categoryWeights,
|
|
enableSentiment,
|
|
whitelist,
|
|
customWordLists,
|
|
})
|
|
|
|
// Debounced toast helper
|
|
const showDebouncedToast = useCallback(
|
|
(type: 'success' | 'error' | 'warning', message: string) => {
|
|
const now = Date.now()
|
|
const minInterval = toastConfig.debounceMs ?? 2000
|
|
|
|
if (now - lastToastTimeRef.current < minInterval) {
|
|
return // Skip toast, too soon
|
|
}
|
|
|
|
lastToastTimeRef.current = now
|
|
|
|
if (type === 'success' && toast?.success) {
|
|
toast.success(message)
|
|
} else if (type === 'error' && toast?.error) {
|
|
toast.error(message)
|
|
} else if (type === 'warning' && toast?.warning) {
|
|
toast.warning(message)
|
|
}
|
|
},
|
|
[toast, toastConfig.debounceMs]
|
|
)
|
|
|
|
// Save function
|
|
const save = useCallback(async () => {
|
|
// Check if content passes flagging
|
|
if (!flagging.passes) {
|
|
setStatus('blocked')
|
|
if (toastConfig.onBlocked) {
|
|
showDebouncedToast('warning', 'Content flagged — cannot save')
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if value changed
|
|
if (value === lastSavedValueRef.current) {
|
|
return // No changes to save
|
|
}
|
|
|
|
setStatus('saving')
|
|
setError(null)
|
|
|
|
// Show loading toast if available
|
|
if (toast?.loading) {
|
|
loadingToastIdRef.current = toast.loading('Saving...')
|
|
}
|
|
|
|
try {
|
|
await onSave(value)
|
|
lastSavedValueRef.current = value
|
|
setStatus('saved')
|
|
|
|
// Update or show success toast
|
|
if (loadingToastIdRef.current && toast?.update) {
|
|
toast.update(loadingToastIdRef.current, 'Saved', 'success')
|
|
loadingToastIdRef.current = null
|
|
} else if (toastConfig.onSave) {
|
|
showDebouncedToast('success', 'Saved')
|
|
}
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Failed to save'
|
|
setError(errorMessage)
|
|
setStatus('error')
|
|
|
|
// Update or show error toast
|
|
if (loadingToastIdRef.current && toast?.update) {
|
|
toast.update(loadingToastIdRef.current, errorMessage, 'error')
|
|
loadingToastIdRef.current = null
|
|
} else if (toastConfig.onError) {
|
|
showDebouncedToast('error', errorMessage)
|
|
}
|
|
}
|
|
}, [value, flagging.passes, onSave, toast, toastConfig, showDebouncedToast])
|
|
|
|
// Reset function
|
|
const reset = useCallback(() => {
|
|
setStatus('idle')
|
|
setError(null)
|
|
if (loadingToastIdRef.current && toast?.dismiss) {
|
|
toast.dismiss(loadingToastIdRef.current)
|
|
loadingToastIdRef.current = null
|
|
}
|
|
}, [toast])
|
|
|
|
// Autosave effect
|
|
useEffect(() => {
|
|
if (!autosaveEnabled) {return}
|
|
|
|
// Clear pending save
|
|
if (autosaveTimerRef.current) {
|
|
clearTimeout(autosaveTimerRef.current)
|
|
}
|
|
|
|
// Don't autosave empty content or if flagging is still analyzing
|
|
if (!value.trim() || flagging.isAnalyzing) {
|
|
return
|
|
}
|
|
|
|
// Don't autosave if content doesn't pass
|
|
if (!flagging.passes) {
|
|
setStatus('blocked')
|
|
return
|
|
}
|
|
|
|
// Check if value changed
|
|
if (value === lastSavedValueRef.current) {
|
|
return
|
|
}
|
|
|
|
// Set pending status
|
|
setStatus('pending')
|
|
|
|
// Schedule autosave
|
|
autosaveTimerRef.current = setTimeout(() => {
|
|
save()
|
|
}, autosaveDelayMs)
|
|
|
|
return () => {
|
|
if (autosaveTimerRef.current) {
|
|
clearTimeout(autosaveTimerRef.current)
|
|
}
|
|
}
|
|
}, [value, autosaveEnabled, autosaveDelayMs, flagging.passes, flagging.isAnalyzing, save])
|
|
|
|
// Update status when flagging state changes
|
|
useEffect(() => {
|
|
if (!flagging.passes && value !== lastSavedValueRef.current) {
|
|
setStatus('blocked')
|
|
} else if (flagging.passes && status === 'blocked') {
|
|
setStatus('pending')
|
|
}
|
|
}, [flagging.passes, value, status])
|
|
|
|
return {
|
|
flagResult: flagging.result,
|
|
passes: flagging.passes,
|
|
score: flagging.score,
|
|
status,
|
|
isAnalyzing: flagging.isAnalyzing,
|
|
isSaving: status === 'saving',
|
|
error,
|
|
save,
|
|
reset,
|
|
}
|
|
} |