Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/__tests__/e2e/rss.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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",
"Feed failed to refresh — see the server execution log":
"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;
3 changes: 2 additions & 1 deletion src/i18n/es/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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",
"Feed failed to refresh — see the server execution log":
"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;
3 changes: 2 additions & 1 deletion src/i18n/ja/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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: "既読",
"Feed failed to refresh — see the server execution log":
"フィードの更新に失敗しました — サーバーの実行ログを確認してください",
Refreshing: "更新中…",
"{name} failed to refresh": "{name} の更新に失敗しました",
"Quick Download": "クイックダウンロード",
} satisfies Dictionary;
3 changes: 2 additions & 1 deletion src/i18n/ko/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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: "읽음",
"Feed failed to refresh — see the server execution log":
"피드 새로 고침 실패 — 서버 실행 로그를 확인하세요",
Refreshing: "새로 고치는 중…",
"{name} failed to refresh": "{name} 새로 고침 실패",
"Quick Download": "빠른 다운로드",
} satisfies Dictionary;
3 changes: 2 additions & 1 deletion src/i18n/pt/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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",
"Feed failed to refresh — see the server execution log":
"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;
3 changes: 2 additions & 1 deletion src/i18n/ru/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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: "Прочитано",
"Feed failed to refresh — see the server execution log":
"Не удалось обновить подписку — подробности в журнале сервера",
Refreshing: "Обновление…",
"{name} failed to refresh": "Не удалось обновить {name}",
"Quick Download": "Быстрая загрузка",
} satisfies Dictionary;
3 changes: 2 additions & 1 deletion src/i18n/zh-TW/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ 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;
3 changes: 2 additions & 1 deletion src/i18n/zh/rss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
12 changes: 10 additions & 2 deletions src/pages/rss/ArticleList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -82,6 +85,7 @@ export function ArticleList({
selectedArticleId,
onSelectArticle,
onDownload,
onQuickDownload,
onRefresh,
}: ArticleListProps) {
const { t } = useTranslation();
Expand Down Expand Up @@ -235,6 +239,10 @@ export function ArticleList({
<Download />
{t("Download")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onQuickDownload(article)}>
<Zap className="text-violet-600 dark:text-violet-400" />
{t("Quick Download")}
</ContextMenuItem>
{article.link && (
<ContextMenuItem onClick={() => window.open(article.link, "_blank")}>
<ExternalLink />
Expand Down
41 changes: 34 additions & 7 deletions src/pages/rss/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ArticleSort>({
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],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -363,13 +382,15 @@ export function RssPage() {
refreshOne,
refreshAll,
downloadArticle,
quickDownloadArticle,
sortedArticles,
feedPaths: [] as string[],
});
hotkeyRef.current = {
refreshOne,
refreshAll,
downloadArticle,
quickDownloadArticle,
sortedArticles,
feedPaths: feedOnly.map((f) => f.path),
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -524,6 +550,7 @@ export function RssPage() {
selectedArticleId={selectedArticleId}
onSelectArticle={setSelectedArticleId}
onDownload={downloadArticle}
onQuickDownload={quickDownloadArticle}
onRefresh={refreshOne}
/>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/stores/ui.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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;
Expand All @@ -33,6 +37,7 @@ type UiState = {
setColumnVisibility: (v: Record<string, boolean>) => 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;
Expand All @@ -47,6 +52,7 @@ export const useUiStore = create<UiState>()(
columnVisibility: {},
columnOrder: [],
torrentSorting: [],
articleSort: { key: "date", dir: "desc" },
lastTorrentStatus: "all",
detailPanelOpen: false,
detailHash: null,
Expand All @@ -67,6 +73,7 @@ export const useUiStore = create<UiState>()(
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 }),
Expand All @@ -85,6 +92,7 @@ export const useUiStore = create<UiState>()(
columnVisibility: s.columnVisibility,
columnOrder: s.columnOrder,
torrentSorting: s.torrentSorting,
articleSort: s.articleSort,
lastTorrentStatus: s.lastTorrentStatus,
detailPanelOpen: s.detailPanelOpen,
}),
Expand Down
Loading