text-processing-content-fla.../src/useAutosaveWithFlagging.ts

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