Files
AeroFetch/src/renderer/src/useClipboardLink.ts
T
debont80 809c99d5bb fix(renderer): L138/L145 clipboard privacy+dedup, M14 state-driven settings search
- 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>
2026-07-01 11:17:23 -04:00

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