feat: private feeds, all input fields supersede keyboard nav

This commit is contained in:
2026-08-09 15:39:02 -04:00
parent e1cdd6b2a5
commit db285530b6
7 changed files with 199 additions and 49 deletions

View File

@@ -12,7 +12,7 @@
*/ */
import { createSignal, Show, For } from "solid-js"; import { createSignal, Show, For } from "solid-js";
import { useKeyboard } from "@opentui/solid"; import { useKeyboard, useRenderer } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useNavigation, NavMode } from "@/context/NavigationContext";
@@ -43,6 +43,7 @@ export function Shell() {
const nav = useNavigation(); const nav = useNavigation();
const k = useKeybinds(); const k = useKeybinds();
const audio = useAudio(); const audio = useAudio();
const renderer = useRenderer();
const audioNav = useAudioNavStore(); const audioNav = useAudioNavStore();
const toast = useToast(); const toast = useToast();
const feedStore = useFeedStore(); const feedStore = useFeedStore();
@@ -205,6 +206,11 @@ export function Shell() {
if (evt.name === "escape") { if (evt.name === "escape") {
evt.preventDefault(); evt.preventDefault();
nav.setInputFocused(false); 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; return;
} }

View File

@@ -0,0 +1,65 @@
/**
* useInputFocusNav — returns a `ref` callback for an `<input>` (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;
}

View File

@@ -1,38 +1,55 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => { const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value let current = value;
return [() => current, (next) => { return [
current = next () => current,
}] (next) => {
} current = next;
},
];
};
import { SyncStatus } from "./SyncStatus" import { SyncStatus } from "./SyncStatus";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
export function ExportDialog() { export function ExportDialog() {
const { theme } = useTheme(); const { theme } = useTheme();
const filename = createSignal("podcast-sync.json") const filename = createSignal("podcast-sync.json");
const format = createSignal<"json" | "xml">("json") const format = createSignal<"json" | "xml">("json");
// Yield navigation keybinds to the Shell router while the input is focused.
const filenameRef = useInputFocusNav();
return ( return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}> <box
<box style={{ flexDirection: "row", gap: 1 }}> border
<text fg={theme.text}>File:</text> title="Export"
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} /> style={{ padding: 1, flexDirection: "column", gap: 1 }}
</box> >
<box style={{ flexDirection: "row", gap: 1 }}> <box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text> <text fg={theme.text}>File:</text>
<tab_select <input
options={[ ref={filenameRef}
{ name: "JSON", description: "Portable" }, value={filename[0]()}
{ name: "XML", description: "Structured" }, onInput={filename[1]}
]} style={{ width: 30 }}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")} />
/> </box>
</box> <box style={{ flexDirection: "row", gap: 1 }}>
<box border borderColor={theme.border}> <text fg={theme.text}>Format:</text>
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text> <tab_select
</box> options={[
<SyncStatus /> { name: "JSON", description: "Portable" },
</box> { name: "XML", description: "Structured" },
) ]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>
Export {format[0]()} to {filename[0]()}
</text>
</box>
<SyncStatus />
</box>
);
} }

View File

@@ -1,24 +1,28 @@
import { detectFormat } from "@/utils/file-detector"; import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
type FilePickerProps = { type FilePickerProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export function FilePicker(props: FilePickerProps) { export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const format = detectFormat(props.value); // Yield navigation keybinds to the Shell router while the input is focused.
const inputRef = useInputFocusNav();
const format = detectFormat(props.value);
return ( return (
<box style={{ flexDirection: "column", gap: 1 }}> <box style={{ flexDirection: "column", gap: 1 }}>
<input <input
value={props.value} ref={inputRef}
onInput={props.onChange} value={props.value}
placeholder="/path/to/sync-file.json" onInput={props.onChange}
style={{ width: 40 }} placeholder="/path/to/sync-file.json"
/> style={{ width: 40 }}
<text fg={theme.text}>Format: {format}</text> />
</box> <text fg={theme.text}>Format: {format}</text>
); </box>
);
} }

View File

@@ -14,6 +14,7 @@
import { createSignal, For, Show } from "solid-js"; import { createSignal, For, Show } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { SourceType } from "@/types/source"; import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source"; import type { PodcastSource } from "@/types/source";
import type { SettingItem } from "./types"; import type { SettingItem } from "./types";
@@ -61,6 +62,9 @@ function AddSourceForm() {
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
const [url, setUrl] = createSignal(""); const [url, setUrl] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
// Yield navigation keybinds to the Shell router while either input is focused.
const nameRef = useInputFocusNav();
const urlRef = useInputFocusNav();
const submit = () => { const submit = () => {
const u = url().trim(); const u = url().trim();
@@ -94,6 +98,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Name:</text> <text fg={theme.textMuted}>Name:</text>
<input <input
ref={nameRef}
value={name()} value={name()}
onInput={setName} onInput={setName}
placeholder="My Custom Feed" placeholder="My Custom Feed"
@@ -103,6 +108,7 @@ function AddSourceForm() {
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>URL:</text> <text fg={theme.textMuted}>URL:</text>
<input <input
ref={urlRef}
value={url()} value={url()}
onInput={(v) => { onInput={(v) => {
setUrl(v); setUrl(v);

View File

@@ -4,7 +4,7 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { searchPodcasts } from "../utils/search"; import { searchPodcasts, searchByFeedUrl } from "../utils/search";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult } from "../types/source"; import type { SearchResult } from "../types/source";
@@ -83,6 +83,15 @@ export function createSearchStore() {
addToHistory(q); addToHistory(q);
try { 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 sources = feedStore.sources();
const enabledSourceIds = sources const enabledSourceIds = sources
.filter((s) => s.enabled) .filter((s) => s.enabled)

View File

@@ -1,4 +1,6 @@
import { searchSourceByType } from "./source-searcher"; import { searchSourceByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser";
import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source"; import type { PodcastSource, SearchResult } from "../types/source";
type SearchCacheEntry = { type SearchCacheEntry = {
@@ -55,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
return Array.from(map.values()); 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<SearchResult[]> => {
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 ( export const searchPodcasts = async (
query: string, query: string,
sourceIds: string[], sourceIds: string[],