809c99d5bb
- L138: gate the clipboard check on the settings `loaded` flag (not just clipboardWatch), so it never reads using the pre-load fallback; the effect re-runs when loaded flips true so a pre-launch copied link is still offered. - L145: lastSeen is module-level, so a dismissed clipboard link stays suppressed across the hook's remount on a Downloads<->Library tab switch. - M14: SettingsView renders each card in a React-owned wrapper and drives its visibility from a `hidden` state array — the search no longer writes display:none onto a card's own DOM node. Matching still reads textContent (full label coverage, no per-card keyword upkeep). typecheck + 268 tests + eslint + prettier green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
4.8 KiB
TypeScript
123 lines
4.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useSettings } from './store/settings'
|
|
// Pure URL helpers extracted to a standalone module so they can be tested
|
|
// without pulling in the Zustand store (L40). Re-exported here for existing
|
|
// consumers (DownloadBar, LibraryView) without an import-path change.
|
|
export {
|
|
looksLikeUrl,
|
|
looksLikeSingleVideo,
|
|
looksLikeChannelOrPlaylist,
|
|
firstUrlInText
|
|
} from './lib/urlHelpers'
|
|
import { looksLikeUrl } from './lib/urlHelpers'
|
|
|
|
/** Where an offered link came from — drives the banner's wording. */
|
|
export type SuggestionSource = 'clipboard' | 'external'
|
|
|
|
interface ClipboardLink {
|
|
/** the link currently being offered, or null */
|
|
suggestion: string | null
|
|
/** where `suggestion` came from: a clipboard guess vs. a link handed in from outside */
|
|
source: SuggestionSource
|
|
/** accept the suggestion: clears it and returns the link (or null if none) */
|
|
accept: () => string | null
|
|
/** dismiss the suggestion without using it */
|
|
dismiss: () => void
|
|
/**
|
|
* Offer a link from outside the clipboard — e.g. the aerofetch:// protocol or a
|
|
* "Send to" .url file. Always takes priority over a clipboard guess and is shown
|
|
* even when clipboard-watch is off (it's a direct request, not a guess) and even
|
|
* while the field has text (the banner only fills the field once accepted).
|
|
* Stable identity, so it's safe to use as an effect dependency.
|
|
*/
|
|
offer: (url: string, source?: SuggestionSource) => void
|
|
}
|
|
|
|
/**
|
|
* Watches the clipboard on window focus and offers a freshly-copied http(s) link.
|
|
* Shared by the download bar and the library's add-source field so both fields
|
|
* can auto-suggest a copied URL. Respects the `clipboardWatch` setting and never
|
|
* interrupts text the user is already typing — pass the field's current value as
|
|
* `currentValue` and the watcher stays quiet while it's non-empty.
|
|
*
|
|
* Pass `filter` to narrow what the clipboard offers — e.g. the library skips
|
|
* single-video links so it only suggests channels/playlists. It gates only the
|
|
* clipboard guess; links pushed via `offer()` bypass it (and the other guards),
|
|
* since they're explicit requests rather than guesses.
|
|
*/
|
|
// Module-level (not a per-instance ref) so a dismissed/accepted link stays
|
|
// suppressed across hook remounts — switching Downloads↔Library remounts the hook,
|
|
// and the same clipboard link shouldn't be re-offered after being dismissed (L145).
|
|
let lastSeen: string | null = null
|
|
|
|
export function useClipboardLink(
|
|
currentValue: string,
|
|
filter?: (url: string) => boolean
|
|
): ClipboardLink {
|
|
const [suggestion, setSuggestion] = useState<string | null>(null)
|
|
const [source, setSource] = useState<SuggestionSource>('clipboard')
|
|
const valueRef = useRef('')
|
|
valueRef.current = currentValue
|
|
// Held in a ref so an inline `filter` doesn't have to be memoized to keep the
|
|
// focus listener from re-subscribing every render.
|
|
const filterRef = useRef(filter)
|
|
filterRef.current = filter
|
|
// Mirror the live suggestion so accept/dismiss can read it without depending on
|
|
// it — keeps their identity stable across renders.
|
|
const suggestionRef = useRef<string | null>(null)
|
|
suggestionRef.current = suggestion
|
|
// Re-run the check once persisted settings arrive, so a link copied before launch
|
|
// is still offered (the mount check is a no-op until `loaded`, L138).
|
|
const loaded = useSettings((s) => s.loaded)
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
async function check(): Promise<void> {
|
|
const s = useSettings.getState()
|
|
// Don't touch the clipboard until real settings have loaded (L138): before
|
|
// that we'd be acting on the pre-load fallback for clipboardWatch instead of
|
|
// the user's actual choice — and we never read it when the watcher is off.
|
|
if (!s.loaded || !s.clipboardWatch || valueRef.current.trim()) return
|
|
let text = ''
|
|
try {
|
|
text = (await window.api.readClipboard()) ?? ''
|
|
} catch {
|
|
return
|
|
}
|
|
if (!active) return
|
|
text = text.trim()
|
|
const wanted = looksLikeUrl(text) && (filterRef.current?.(text) ?? true)
|
|
if (wanted && text !== lastSeen) {
|
|
setSource('clipboard')
|
|
setSuggestion(text)
|
|
}
|
|
}
|
|
check()
|
|
window.addEventListener('focus', check)
|
|
return () => {
|
|
active = false
|
|
window.removeEventListener('focus', check)
|
|
}
|
|
}, [loaded])
|
|
|
|
const accept = useCallback((): string | null => {
|
|
const link = suggestionRef.current
|
|
if (!link) return null
|
|
lastSeen = link
|
|
setSuggestion(null)
|
|
return link
|
|
}, [])
|
|
|
|
const dismiss = useCallback((): void => {
|
|
lastSeen = suggestionRef.current
|
|
setSuggestion(null)
|
|
}, [])
|
|
|
|
const offer = useCallback((url: string, src: SuggestionSource = 'external'): void => {
|
|
setSource(src)
|
|
setSuggestion(url)
|
|
}, [])
|
|
|
|
return { suggestion, source, accept, dismiss, offer }
|
|
}
|