From db285530b6d34705df863b3e73ae1df3c940f743 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 15:39:02 -0400 Subject: [PATCH] feat: private feeds, all input fields supersede keyboard nav --- src/components/Shell.tsx | 8 ++- src/hooks/useInputFocusNav.ts | 65 ++++++++++++++++++++++ src/pages/Settings/ExportDialog.tsx | 81 +++++++++++++++++----------- src/pages/Settings/FilePicker.tsx | 34 ++++++------ src/pages/Settings/SourceManager.tsx | 6 +++ src/stores/search.ts | 11 +++- src/utils/search.ts | 43 +++++++++++++++ 7 files changed, 199 insertions(+), 49 deletions(-) create mode 100644 src/hooks/useInputFocusNav.ts diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 19641b8..c436db4 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -12,7 +12,7 @@ */ import { createSignal, Show, For } from "solid-js"; -import { useKeyboard } from "@opentui/solid"; +import { useKeyboard, useRenderer } from "@opentui/solid"; import { useTheme } from "@/context/ThemeContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useNavigation, NavMode } from "@/context/NavigationContext"; @@ -43,6 +43,7 @@ export function Shell() { const nav = useNavigation(); const k = useKeybinds(); const audio = useAudio(); + const renderer = useRenderer(); const audioNav = useAudioNavStore(); const toast = useToast(); const feedStore = useFeedStore(); @@ -205,6 +206,11 @@ export function Shell() { if (evt.name === "escape") { evt.preventDefault(); nav.setInputFocused(false); + // Actually blur the focused renderable too — setting the flag alone + // leaves the opentui input owning keys, so nav keys would still be + // typed into it. Blurring fires our useInputFocusNav BLURRED handler + // (and re-blurs the SearchPage input via its `focused` prop). + renderer.currentFocusedRenderable?.blur(); } return; } diff --git a/src/hooks/useInputFocusNav.ts b/src/hooks/useInputFocusNav.ts new file mode 100644 index 0000000..dc17030 --- /dev/null +++ b/src/hooks/useInputFocusNav.ts @@ -0,0 +1,65 @@ +/** + * useInputFocusNav — returns a `ref` callback for an `` (or any + * focusable renderable) that holds the navigation store's `inputFocused` + * flag true while the renderable has focus. + * + * Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to + * whatever is focused only when `nav.inputFocused()` is true; otherwise it + * dispatches navigation keybinds (j/k/h/…). Forms rendered inside the + * depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that + * flag, so typing into them *also* fired the navigation keybinds. Wiring the + * flag to each input's real focus/blur state fixes that. + * + * A module-level counter guards the blur→focus ordering gap that occurs when + * tabbing between two inputs in the same form (the old input blurs before the + * new one focuses) so the flag never flickers off mid-handoff. + */ + +import { onCleanup } from "solid-js"; +import { RenderableEvents } from "@opentui/core"; +import { useNavigation } from "@/context/NavigationContext"; + +// Inputs (managed by this hook) currently holding focus. +let focusedCount = 0; + +export function useInputFocusNav() { + const nav = useNavigation(); + let current: any | undefined; + + const onFocused = () => { + focusedCount++; + nav.setInputFocused(true); + }; + const onBlurred = () => { + focusedCount = Math.max(0, focusedCount - 1); + if (focusedCount === 0) nav.setInputFocused(false); + }; + + const detach = (el: any) => { + el.off(RenderableEvents.FOCUSED, onFocused); + el.off(RenderableEvents.BLURRED, onBlurred); + // Treat a focused element being torn down as a blur so the counter + // doesn't leak and leave inputFocused stuck on. + if (el.focused) onBlurred(); + }; + + const ref = (el: any) => { + if (current && current !== el) detach(current); + current = el; + if (el) { + el.on(RenderableEvents.FOCUSED, onFocused); + el.on(RenderableEvents.BLURRED, onBlurred); + // If the renderable is already focused when attached, count it. + if (el.focused) onFocused(); + } + }; + + onCleanup(() => { + if (current) { + detach(current); + current = undefined; + } + }); + + return ref; +} diff --git a/src/pages/Settings/ExportDialog.tsx b/src/pages/Settings/ExportDialog.tsx index 809d10d..6d28cf4 100644 --- a/src/pages/Settings/ExportDialog.tsx +++ b/src/pages/Settings/ExportDialog.tsx @@ -1,38 +1,55 @@ const createSignal = (value: T): [() => T, (next: T) => void] => { - let current = value - return [() => current, (next) => { - current = next - }] -} + let current = value; + return [ + () => current, + (next) => { + current = next; + }, + ]; +}; -import { SyncStatus } from "./SyncStatus" -import { useTheme } from "@/context/ThemeContext" +import { SyncStatus } from "./SyncStatus"; +import { useTheme } from "@/context/ThemeContext"; +import { useInputFocusNav } from "@/hooks/useInputFocusNav"; export function ExportDialog() { - const { theme } = useTheme(); - const filename = createSignal("podcast-sync.json") - const format = createSignal<"json" | "xml">("json") + const { theme } = useTheme(); + const filename = createSignal("podcast-sync.json"); + const format = createSignal<"json" | "xml">("json"); + // Yield navigation keybinds to the Shell router while the input is focused. + const filenameRef = useInputFocusNav(); - return ( - - - File: - - - - Format: - format[1](index === 0 ? "json" : "xml")} - /> - - - Export {format[0]()} to {filename[0]()} - - - - ) + return ( + + + File: + + + + Format: + format[1](index === 0 ? "json" : "xml")} + /> + + + + Export {format[0]()} to {filename[0]()} + + + + + ); } diff --git a/src/pages/Settings/FilePicker.tsx b/src/pages/Settings/FilePicker.tsx index 5caa90d..b126a5c 100644 --- a/src/pages/Settings/FilePicker.tsx +++ b/src/pages/Settings/FilePicker.tsx @@ -1,24 +1,28 @@ import { detectFormat } from "@/utils/file-detector"; import { useTheme } from "@/context/ThemeContext"; +import { useInputFocusNav } from "@/hooks/useInputFocusNav"; type FilePickerProps = { - value: string; - onChange: (value: string) => void; + value: string; + onChange: (value: string) => void; }; export function FilePicker(props: FilePickerProps) { - const { theme } = useTheme(); - const format = detectFormat(props.value); + const { theme } = useTheme(); + // Yield navigation keybinds to the Shell router while the input is focused. + const inputRef = useInputFocusNav(); + const format = detectFormat(props.value); - return ( - - - Format: {format} - - ); + return ( + + + Format: {format} + + ); } diff --git a/src/pages/Settings/SourceManager.tsx b/src/pages/Settings/SourceManager.tsx index 948ff69..1cc91be 100644 --- a/src/pages/Settings/SourceManager.tsx +++ b/src/pages/Settings/SourceManager.tsx @@ -14,6 +14,7 @@ import { createSignal, For, Show } from "solid-js"; import { useFeedStore } from "@/stores/feed"; import { useTheme } from "@/context/ThemeContext"; +import { useInputFocusNav } from "@/hooks/useInputFocusNav"; import { SourceType } from "@/types/source"; import type { PodcastSource } from "@/types/source"; import type { SettingItem } from "./types"; @@ -61,6 +62,9 @@ function AddSourceForm() { const [name, setName] = createSignal(""); const [url, setUrl] = createSignal(""); const [error, setError] = createSignal(null); + // Yield navigation keybinds to the Shell router while either input is focused. + const nameRef = useInputFocusNav(); + const urlRef = useInputFocusNav(); const submit = () => { const u = url().trim(); @@ -94,6 +98,7 @@ function AddSourceForm() { Name: URL: { setUrl(v); diff --git a/src/stores/search.ts b/src/stores/search.ts index ddf5a9d..38dc862 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -4,7 +4,7 @@ */ import { createSignal } from "solid-js"; -import { searchPodcasts } from "../utils/search"; +import { searchPodcasts, searchByFeedUrl } from "../utils/search"; import { useFeedStore } from "./feed"; import type { SearchResult } from "../types/source"; @@ -83,6 +83,15 @@ export function createSearchStore() { addToHistory(q); try { + // A query that is a direct RSS feed URL (e.g. a private feed that + // isn't in any public directory) resolves to that feed directly, + // independent of enabled search sources. + const urlResults = await searchByFeedUrl(q); + if (urlResults.length > 0) { + setResults(applySubscribedStatus(urlResults)); + return; + } + const sources = feedStore.sources(); const enabledSourceIds = sources .filter((s) => s.enabled) diff --git a/src/utils/search.ts b/src/utils/search.ts index 6bea1fd..4783b12 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -1,4 +1,6 @@ import { searchSourceByType } from "./source-searcher"; +import { parseRSSFeed } from "../api/rss-parser"; +import { SourceType } from "../types/source"; import type { PodcastSource, SearchResult } from "../types/source"; type SearchCacheEntry = { @@ -55,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => { return Array.from(map.values()); }; +const FEED_URL_RE = /^https?:\/\/.+/i; + +/** + * If the query is a direct RSS feed URL (useful for private feeds that aren't + * in public directories), fetch and parse it into a single search result. + * Returns an empty array when the query is not a URL so normal search proceeds. + */ +export const searchByFeedUrl = async ( + query: string, +): Promise => { + const trimmed = query.trim(); + if (!FEED_URL_RE.test(trimmed)) return []; + + try { + const response = await fetch(trimmed, { + headers: { + "Accept-Encoding": "identity", + Accept: "application/rss+xml, application/xml, text/xml, */*", + }, + }); + if (!response.ok) return []; + + const xml = await response.text(); + const podcast = parseRSSFeed(xml, trimmed); + + return [ + { + sourceId: "direct-rss", + sourceName: "RSS Feed", + sourceType: SourceType.RSS, + // parseRSSFeed marks feeds subscribed; a search result should start + // unsubscribed so the store can flag it correctly if already added. + podcast: { ...podcast, isSubscribed: false }, + score: 1, + }, + ]; + } catch { + return []; + } +}; + export const searchPodcasts = async ( query: string, sourceIds: string[],