diff --git a/src/__tests__/e2e/rss.spec.ts b/src/__tests__/e2e/rss.spec.ts
index 4ba9562..581a23a 100644
--- a/src/__tests__/e2e/rss.spec.ts
+++ b/src/__tests__/e2e/rss.spec.ts
@@ -92,6 +92,66 @@ test("clicking an article opens the add dialog with article context", async ({ p
await expect.poll(() => addBody).toContain("magnet:?xt=v2");
});
+test("quick download adds the torrent directly without the dialog", async ({ page }) => {
+ let addBody = "";
+ await page.route("**/api/v2/torrents/add", (route) => {
+ addBody = route.request().postData() ?? "";
+ route.fulfill({ status: 200, body: "Ok." });
+ });
+
+ await feedRow(page).click();
+ // real right-click: a plain Event has button undefined (fixtures README)
+ await page.getByText("New Release v2.0").evaluate((el) => {
+ el.dispatchEvent(new MouseEvent("contextmenu", { button: 2, bubbles: true }));
+ });
+ await page.getByRole("menuitem", { name: "Quick Download" }).click();
+ // POSTs the magnet straight away with server-default parameters
+ await expect.poll(() => addBody).toContain("magnet:?xt=v2");
+ // the add-torrent dialog never opens, and the flow reports success
+ await expect(page.getByRole("dialog")).toBeHidden();
+ await expect(page.locator("[data-sonner-toast]").getByText("Added successfully")).toBeVisible();
+});
+
+test("Shift+Enter on the selected article quick-downloads it", async ({ page }) => {
+ let addBody = "";
+ await page.route("**/api/v2/torrents/add", (route) => {
+ addBody = route.request().postData() ?? "";
+ route.fulfill({ status: 200, body: "Ok." });
+ });
+
+ await feedRow(page).click();
+ await page.keyboard.press("j"); // select the first (unread) article
+ await page.keyboard.press("Shift+Enter");
+ await expect.poll(() => addBody).toContain("magnet:?xt=v2");
+ // still no dialog — Shift+Enter is the keyboard path of Quick Download
+ await expect(page.getByRole("dialog")).toBeHidden();
+});
+
+test("article sort survives switching pages and reloads", async ({ page }) => {
+ const options = () => page.locator('[role="option"]');
+ await feedRow(page).click();
+ await expect(options()).toHaveCount(2);
+ // default date-desc puts New Release first; sorting by title flips it
+ await expect(options().first()).toContainText("New Release v2.0");
+ await page.getByRole("button", { name: "Title" }).click();
+ await expect(options().first()).toContainText("Bug Fix v1.9");
+
+ // leave the RSS page and come back (hash navigation, no reload)
+ await page.evaluate(() => {
+ window.location.hash = "#/";
+ });
+ await page.evaluate(() => {
+ window.location.hash = "#/rss";
+ });
+ await expect(options()).toHaveCount(2);
+ await expect(options().first()).toContainText("Bug Fix v1.9");
+
+ // the choice also lives in the persisted ui store (survives reloads)
+ const stored = await page.evaluate(() => localStorage.getItem("qiubi-ui"));
+ expect(stored).toContain('"articleSort"');
+ expect(stored).toContain('"key":"title"');
+});
+
test("add feed dialog opens", async ({ page }) => {
await page.getByRole("button", { name: /Add Feed/i }).click();
await expect(page.getByRole("heading", { name: /Add Feed/i })).toBeVisible();
diff --git a/src/i18n/en/rss.ts b/src/i18n/en/rss.ts
index 667f5db..734a438 100644
--- a/src/i18n/en/rss.ts
+++ b/src/i18n/en/rss.ts
@@ -38,7 +38,7 @@ export default {
"RSS is disabled on the server — enable it in Settings → RSS",
"Copy Feed URL": "Copy Feed URL",
"RSS hotkeys hint":
- "j/k select article · Shift+J/K switch feed · Enter download · r refresh · Shift+R refresh all",
+ "j/k select article · Shift+J/K switch feed · Enter download · Shift+Enter quick download · r refresh · Shift+R refresh all",
Title: "Title",
Date: "Date",
Read: "Read",
@@ -46,4 +46,5 @@ export default {
"Feed failed to refresh — see the server execution log",
Refreshing: "Refreshing…",
"{name} failed to refresh": "{name} failed to refresh",
+ "Quick Download": "Quick Download",
} satisfies Dictionary;
diff --git a/src/i18n/es/rss.ts b/src/i18n/es/rss.ts
index 1bc36c4..ff5519a 100644
--- a/src/i18n/es/rss.ts
+++ b/src/i18n/es/rss.ts
@@ -38,7 +38,7 @@ export default {
"RSS está desactivado en el servidor — actívalo en Ajustes → RSS",
"Copy Feed URL": "Copiar URL del canal",
"RSS hotkeys hint":
- "j/k elegir artículo · Shift+J/K cambiar fuente · Enter descargar · r actualizar · Shift+R actualizar todo",
+ "j/k elegir artículo · Shift+J/K cambiar fuente · Enter descargar · Shift+Enter descarga rápida · r actualizar · Shift+R actualizar todo",
Title: "Título",
Date: "Fecha",
Read: "Leído",
@@ -46,4 +46,5 @@ export default {
"El canal no se pudo actualizar — consulta el registro de ejecución del servidor",
Refreshing: "Actualizando…",
"{name} failed to refresh": "Error al actualizar {name}",
+ "Quick Download": "Descarga rápida",
} satisfies Dictionary;
diff --git a/src/i18n/ja/rss.ts b/src/i18n/ja/rss.ts
index bb224b6..8e1758e 100644
--- a/src/i18n/ja/rss.ts
+++ b/src/i18n/ja/rss.ts
@@ -38,7 +38,7 @@ export default {
"サーバーで RSS が無効です — 設定 → RSS で有効にしてください",
"Copy Feed URL": "フィードURLをコピー",
"RSS hotkeys hint":
- "j/k 記事を選択 · Shift+J/K フィード切替 · Enter ダウンロード · r 更新 · Shift+R すべて更新",
+ "j/k 記事を選択 · Shift+J/K フィード切替 · Enter ダウンロード · Shift+Enter クイックダウンロード · r 更新 · Shift+R すべて更新",
Title: "タイトル",
Date: "日付",
Read: "既読",
@@ -46,4 +46,5 @@ export default {
"フィードの更新に失敗しました — サーバーの実行ログを確認してください",
Refreshing: "更新中…",
"{name} failed to refresh": "{name} の更新に失敗しました",
+ "Quick Download": "クイックダウンロード",
} satisfies Dictionary;
diff --git a/src/i18n/ko/rss.ts b/src/i18n/ko/rss.ts
index 09465f8..c2735e9 100644
--- a/src/i18n/ko/rss.ts
+++ b/src/i18n/ko/rss.ts
@@ -38,7 +38,7 @@ export default {
"서버에서 RSS가 비활성되어 있습니다 — 설정 → RSS에서 활성화하세요",
"Copy Feed URL": "피드 URL 복사",
"RSS hotkeys hint":
- "j/k 기사 선택 · Shift+J/K 피드 전환 · Enter 다운로드 · r 새로 고침 · Shift+R 모두 새로 고침",
+ "j/k 기사 선택 · Shift+J/K 피드 전환 · Enter 다운로드 · Shift+Enter 빠른 다운로드 · r 새로 고침 · Shift+R 모두 새로 고침",
Title: "제목",
Date: "날짜",
Read: "읽음",
@@ -46,4 +46,5 @@ export default {
"피드 새로 고침 실패 — 서버 실행 로그를 확인하세요",
Refreshing: "새로 고치는 중…",
"{name} failed to refresh": "{name} 새로 고침 실패",
+ "Quick Download": "빠른 다운로드",
} satisfies Dictionary;
diff --git a/src/i18n/pt/rss.ts b/src/i18n/pt/rss.ts
index 9619da3..b41ad3f 100644
--- a/src/i18n/pt/rss.ts
+++ b/src/i18n/pt/rss.ts
@@ -38,7 +38,7 @@ export default {
"RSS está desativado no servidor — ative em Configurações → RSS",
"Copy Feed URL": "Copiar URL do feed",
"RSS hotkeys hint":
- "j/k escolher artigo · Shift+J/K trocar feed · Enter baixar · r atualizar · Shift+R atualizar tudo",
+ "j/k escolher artigo · Shift+J/K trocar feed · Enter baixar · Shift+Enter download rápido · r atualizar · Shift+R atualizar tudo",
Title: "Título",
Date: "Data",
Read: "Lido",
@@ -46,4 +46,5 @@ export default {
"O feed falhou ao atualizar — veja o log de execução do servidor",
Refreshing: "Atualizando…",
"{name} failed to refresh": "Falha ao atualizar {name}",
+ "Quick Download": "Download rápido",
} satisfies Dictionary;
diff --git a/src/i18n/ru/rss.ts b/src/i18n/ru/rss.ts
index e7a3d71..668a9ac 100644
--- a/src/i18n/ru/rss.ts
+++ b/src/i18n/ru/rss.ts
@@ -38,7 +38,7 @@ export default {
"RSS отключён на сервере — включите в Настройки → RSS",
"Copy Feed URL": "Копировать ссылку на ленту",
"RSS hotkeys hint":
- "j/k выбор статьи · Shift+J/K смена ленты · Enter скачать · r обновить · Shift+R обновить все",
+ "j/k выбор статьи · Shift+J/K смена ленты · Enter скачать · Shift+Enter быстрая загрузка · r обновить · Shift+R обновить все",
Title: "Заголовок",
Date: "Дата",
Read: "Прочитано",
@@ -46,4 +46,5 @@ export default {
"Не удалось обновить подписку — подробности в журнале сервера",
Refreshing: "Обновление…",
"{name} failed to refresh": "Не удалось обновить {name}",
+ "Quick Download": "Быстрая загрузка",
} satisfies Dictionary;
diff --git a/src/i18n/zh-TW/rss.ts b/src/i18n/zh-TW/rss.ts
index 4be83d8..fd34aab 100644
--- a/src/i18n/zh-TW/rss.ts
+++ b/src/i18n/zh-TW/rss.ts
@@ -38,7 +38,7 @@ export default {
"RSS 功能已在服務端關閉,請到 設定 → RSS 中開啟",
"Copy Feed URL": "複製 RSS 來源網址",
"RSS hotkeys hint":
- "j/k 選擇文章 · Shift+J/K 切換訂閱源 · Enter 下載 · r 重新整理 · Shift+R 全部重新整理",
+ "j/k 選擇文章 · Shift+J/K 切換訂閱源 · Enter 下載 · Shift+Enter 快速下載 · r 重新整理 · Shift+R 全部重新整理",
Title: "標題",
Date: "日期",
Read: "已讀",
@@ -46,4 +46,5 @@ export default {
"訂閱源重新整理失敗 — 詳情見伺服器執行記錄",
Refreshing: "正在重新整理…",
"{name} failed to refresh": "{name} 重新整理失敗",
+ "Quick Download": "快速下載",
} satisfies Dictionary;
diff --git a/src/i18n/zh/rss.ts b/src/i18n/zh/rss.ts
index 6ba408a..4ef28f8 100644
--- a/src/i18n/zh/rss.ts
+++ b/src/i18n/zh/rss.ts
@@ -38,11 +38,12 @@ export default {
"RSS 功能已在服务端关闭,请到 设置 → RSS 中开启",
"Copy Feed URL": "复制 RSS 源地址",
"RSS hotkeys hint":
- "j/k 选择文章 · Shift+J/K 切换订阅源 · Enter 下载 · r 刷新 · Shift+R 全部刷新",
+ "j/k 选择文章 · Shift+J/K 切换订阅源 · Enter 下载 · Shift+Enter 快速下载 · r 刷新 · Shift+R 全部刷新",
Title: "标题",
Date: "日期",
Read: "已读",
"Feed failed to refresh — see the server execution log": "订阅源刷新失败 — 详情见服务端执行日志",
Refreshing: "正在刷新…",
"{name} failed to refresh": "{name} 刷新失败",
+ "Quick Download": "快速下载",
} satisfies Dictionary;
diff --git a/src/pages/rss/ArticleList.tsx b/src/pages/rss/ArticleList.tsx
index ce8313f..3ede09a 100644
--- a/src/pages/rss/ArticleList.tsx
+++ b/src/pages/rss/ArticleList.tsx
@@ -1,4 +1,4 @@
-import { ChevronRight, Download, ExternalLink, RefreshCw, Rss } from "lucide-react";
+import { ChevronRight, Download, ExternalLink, RefreshCw, Rss, Zap } from "lucide-react";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Shortcut } from "@/components/Shortcut";
@@ -67,13 +67,16 @@ interface ArticleListProps {
/** Click / "Download" menu item → open the add-torrent dialog with article
* context */
onDownload: (article: RssArticle) => void;
+ /** "Quick Download" menu item → add with server defaults, no dialog */
+ onQuickDownload: (article: RssArticle) => void;
/** Refresh just the selected feed */
onRefresh: (path: string) => void;
}
/** Right column: article table — sortable by title / date / read status.
* Click opens the add dialog (with article context); right-click /
- * long-press menu offers download or opening the article's webpage. */
+ * long-press menu offers download, quick download (server defaults, no
+ * dialog) or opening the article's webpage. */
export function ArticleList({
selectedFeed,
articles,
@@ -82,6 +85,7 @@ export function ArticleList({
selectedArticleId,
onSelectArticle,
onDownload,
+ onQuickDownload,
onRefresh,
}: ArticleListProps) {
const { t } = useTranslation();
@@ -235,6 +239,10 @@ export function ArticleList({
{t("Download")}
+ onQuickDownload(article)}>
+
+ {t("Quick Download")}
+
{article.link && (
window.open(article.link, "_blank")}>
diff --git a/src/pages/rss/index.tsx b/src/pages/rss/index.tsx
index e81a6b9..9b0b224 100644
--- a/src/pages/rss/index.tsx
+++ b/src/pages/rss/index.tsx
@@ -20,8 +20,9 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { pollWithBackoff } from "@/hooks/useMainDataSync";
+import { useUiStore } from "@/stores/ui";
import type { AppPreferences, RssArticle, RssFeed } from "@/types/qbt";
-import { ArticleList, type ArticleSort, sortArticles } from "./ArticleList";
+import { ArticleList, sortArticles } from "./ArticleList";
import { FeedTree, flattenFeeds } from "./FeedTree";
/** Apply fn to the map that CONTAINS the feed/folder at `path` — used for
@@ -92,11 +93,11 @@ export function RssPage() {
const selected = flat.find((x) => x.path === selectedFeed);
const selectedArticles: RssArticle[] = selected?.feed.articles ?? [];
/** Display order (date-desc by default; the article table headers can
- * re-sort) — the exact order ArticleList renders; keyboard nav walks it */
- const [articleSort, setArticleSort] = useState({
- key: "date",
- dir: "desc",
- });
+ * re-sort) — the exact order ArticleList renders; keyboard nav walks it.
+ * Lives in the persistent ui store so the choice survives page switches
+ * and reloads, like the torrent table's sorting. */
+ const articleSort = useUiStore((s) => s.articleSort);
+ const setArticleSort = useUiStore((s) => s.setArticleSort);
const sortedArticles = useMemo(
() => sortArticles(selectedArticles, articleSort),
[selectedArticles, articleSort],
@@ -225,6 +226,24 @@ export function RssPage() {
});
};
+ /** Context-menu "Quick Download": add the article's torrent with the
+ * server's default parameters — no dialog round-trip. Counts as reading
+ * like the dialog flow does. */
+ const quickDownloadArticle = async (article: RssArticle) => {
+ try {
+ await qbtClient.addTorrent({ urls: article.torrentURL || article.link });
+ toast.success(t("Added successfully"));
+ if (selectedFeed && !article.isRead) {
+ qbtClient
+ .markRssAsRead(selectedFeed, article.id)
+ .then(() => qc.invalidateQueries({ queryKey: ["rss-feeds"] }))
+ .catch(() => {});
+ }
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : String(e));
+ }
+ };
+
/**
* qBT's refreshItem is fire-and-forget — it 200s before the fetch even
* starts (and silently for an unknown itemPath), so the real outcome only
@@ -363,6 +382,7 @@ export function RssPage() {
refreshOne,
refreshAll,
downloadArticle,
+ quickDownloadArticle,
sortedArticles,
feedPaths: [] as string[],
});
@@ -370,6 +390,7 @@ export function RssPage() {
refreshOne,
refreshAll,
downloadArticle,
+ quickDownloadArticle,
sortedArticles,
feedPaths: feedOnly.map((f) => f.path),
};
@@ -415,7 +436,12 @@ export function RssPage() {
}
if (e.key === "Enter") {
const article = hotkeyRef.current.sortedArticles.find((a) => a.id === selectedArticleId);
- if (article) void hotkeyRef.current.downloadArticle(article);
+ // Enter opens the prefilled add dialog; Shift+Enter quick-downloads
+ // with server defaults, no dialog
+ if (article)
+ void (e.shiftKey
+ ? hotkeyRef.current.quickDownloadArticle(article)
+ : hotkeyRef.current.downloadArticle(article));
return;
}
if (e.key !== "r" && e.key !== "R") return;
@@ -524,6 +550,7 @@ export function RssPage() {
selectedArticleId={selectedArticleId}
onSelectArticle={setSelectedArticleId}
onDownload={downloadArticle}
+ onQuickDownload={quickDownloadArticle}
onRefresh={refreshOne}
/>
diff --git a/src/stores/ui.ts b/src/stores/ui.ts
index fd96862..99ac931 100644
--- a/src/stores/ui.ts
+++ b/src/stores/ui.ts
@@ -1,6 +1,7 @@
import type { SortingState } from "@tanstack/react-table";
import { create } from "zustand";
import { persist } from "zustand/middleware";
+import type { ArticleSort } from "@/pages/rss/ArticleList";
type UiState = {
/** View menu: show/hide the filter sidebar */
@@ -15,6 +16,9 @@ type UiState = {
columnOrder: string[];
/** Torrent list sorting (header click), persisted across reloads */
torrentSorting: SortingState;
+ /** RSS article table sorting (header click), persisted across reloads and
+ * page switches — the same ArticleSort the article table sorts by */
+ articleSort: ArticleSort;
/** Last status filter chosen in the toolbar; a fresh open without ?status=
* in the URL seeds the filter from it (explicit URLs always win) */
lastTorrentStatus: string;
@@ -33,6 +37,7 @@ type UiState = {
setColumnVisibility: (v: Record) => void;
setColumnOrder: (order: string[]) => void;
setTorrentSorting: (updater: SortingState | ((prev: SortingState) => SortingState)) => void;
+ setArticleSort: (sort: ArticleSort) => void;
setLastTorrentStatus: (status: string) => void;
setDetailPanelOpen: (open: boolean) => void;
setDetailHash: (hash: string | null) => void;
@@ -47,6 +52,7 @@ export const useUiStore = create()(
columnVisibility: {},
columnOrder: [],
torrentSorting: [],
+ articleSort: { key: "date", dir: "desc" },
lastTorrentStatus: "all",
detailPanelOpen: false,
detailHash: null,
@@ -67,6 +73,7 @@ export const useUiStore = create()(
set((s) => ({
torrentSorting: typeof updater === "function" ? updater(s.torrentSorting) : updater,
})),
+ setArticleSort: (articleSort) => set({ articleSort }),
setLastTorrentStatus: (lastTorrentStatus) => set({ lastTorrentStatus }),
setDetailPanelOpen: (detailPanelOpen) => set({ detailPanelOpen }),
setDetailHash: (detailHash) => set({ detailHash }),
@@ -85,6 +92,7 @@ export const useUiStore = create()(
columnVisibility: s.columnVisibility,
columnOrder: s.columnOrder,
torrentSorting: s.torrentSorting,
+ articleSort: s.articleSort,
lastTorrentStatus: s.lastTorrentStatus,
detailPanelOpen: s.detailPanelOpen,
}),