Skip to content

Commit fa08056

Browse files
committed
fix(reader,sidebar): persist sidebar search and improve feed-description rendering for Kagi
1 parent 342d4bc commit fa08056

File tree

6 files changed

+193
-49
lines changed

6 files changed

+193
-49
lines changed

docs/releases/2.2.0-beta.3.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,15 @@
2020
- Full SQLite sync is deferred while background import is active, then executed once at completion
2121
- Sidebar import-state lookup was optimized to avoid O(N²) queue scans during massive imports
2222
- Feed rows remain more responsive/clickable during long-running imports
23+
24+
#### Reader Experience
25+
26+
- Improved reader fallback: when full-page extraction is weak/empty, reader now falls back to feed-provided HTML (`content`/`description`)
27+
- Added Kagi-specific handling (`kite.kagi.com` / `news.kagi.com`) so embedded feed content renders directly in Reader view
28+
- Added a collapsible **Feed description** callout block in Reader view (expanded by default)
29+
- Removed the separate top cover image block so reader content starts immediately with article text
30+
31+
#### Sidebar Search Workflow
32+
33+
- Sidebar feed search query now persists across sidebar re-renders
34+
- After clicking a searched feed/folder, search results remain filtered so you can open matches one by one without retyping

main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,7 @@ export default class RssDashboardPlugin extends Plugin {
918918
const iconSpan = this.importStatusBarItem.createSpan({
919919
cls: "import-statusbar-icon",
920920
});
921-
setIcon(iconSpan, "wifi");
921+
setIcon(iconSpan, "rss");
922922
this.importStatusBarItem.createSpan({
923923
cls: "import-statusbar-text",
924924
});

src/components/sidebar.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export class Sidebar {
223223
private plugin: RssDashboardPlugin;
224224
private cachedFolderPaths: string[] | null = null;
225225
private isSearchExpanded = false;
226+
private searchQuery = "";
226227
// Legacy toggle-row state (disabled by design after header toolbar migration).
227228
// private isSidebarToolbarCollapsed = false;
228229
private isTagsExpanded = false;
@@ -389,6 +390,7 @@ export class Sidebar {
389390
// }
390391
this.renderSearchDock(controlsSurface);
391392
this.renderFeedFolders();
393+
this.applySearchFilterFromState();
392394

393395
requestAnimationFrame(() => {
394396
this.container.scrollTop = scrollPosition;
@@ -1922,6 +1924,7 @@ export class Sidebar {
19221924
placeholder: "Search feeds...",
19231925
autocomplete: "off",
19241926
spellcheck: "false",
1927+
value: this.searchQuery,
19251928
},
19261929
});
19271930
const clearButton = searchContainer.createEl("button", {
@@ -1955,9 +1958,9 @@ export class Sidebar {
19551958

19561959
let searchTimeout: number;
19571960
searchInput.addEventListener("input", (e) => {
1958-
const query = ((e.target as HTMLInputElement)?.value || "")
1959-
.toLowerCase()
1960-
.trim();
1961+
const rawQuery = (e.target as HTMLInputElement)?.value || "";
1962+
this.searchQuery = rawQuery;
1963+
const query = rawQuery.toLowerCase().trim();
19611964
updateClearButtonVisibility();
19621965
if (searchTimeout) {
19631966
window.clearTimeout(searchTimeout);
@@ -1970,6 +1973,7 @@ export class Sidebar {
19701973
clearButton.addEventListener("click", (e) => {
19711974
e.preventDefault();
19721975
searchInput.value = "";
1976+
this.searchQuery = "";
19731977
updateClearButtonVisibility();
19741978
if (searchTimeout) {
19751979
window.clearTimeout(searchTimeout);
@@ -1980,6 +1984,7 @@ export class Sidebar {
19801984

19811985
requestAnimationFrame(() => {
19821986
searchInput.focus();
1987+
updateClearButtonVisibility();
19831988
if (window.innerWidth <= 768) {
19841989
window.setTimeout(() => {
19851990
searchInput.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -2659,6 +2664,13 @@ export class Sidebar {
26592664
}
26602665
}
26612666

2667+
private applySearchFilterFromState(): void {
2668+
const query = this.isSearchExpanded
2669+
? this.searchQuery.toLowerCase().trim()
2670+
: "";
2671+
this.filterFeedsAndFolders(query);
2672+
}
2673+
26622674
/**
26632675
* Clear the search and show all feeds/folders
26642676
*/

src/styles/reader.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,47 @@
118118
line-height: 1.6;
119119
color: var(--text-normal);
120120
}
121+
122+
.rss-reader-description-callout {
123+
margin: 0 0 16px;
124+
border: 1px solid var(--background-modifier-border);
125+
border-radius: 8px;
126+
background: var(--background-secondary);
127+
overflow: hidden;
128+
}
129+
130+
.rss-reader-description-callout > summary {
131+
cursor: pointer;
132+
list-style: none;
133+
padding: 10px 12px;
134+
font-weight: 600;
135+
color: var(--text-normal);
136+
border-bottom: 1px solid transparent;
137+
}
138+
139+
.rss-reader-description-callout > summary::-webkit-details-marker {
140+
display: none;
141+
}
142+
143+
.rss-reader-description-callout > summary::before {
144+
content: "+";
145+
display: inline-block;
146+
width: 16px;
147+
color: var(--text-muted);
148+
font-weight: 700;
149+
}
150+
151+
.rss-reader-description-callout[open] > summary::before {
152+
content: "-";
153+
}
154+
155+
.rss-reader-description-callout[open] > summary {
156+
border-bottom-color: var(--background-modifier-border);
157+
}
158+
159+
.rss-reader-description-body {
160+
padding: 12px;
161+
}
121162
.rss-reader-article-content img,
122163
.rss-reader-description img {
123164
max-width: 100%;

src/views/reader-view.ts

Lines changed: 123 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,10 @@ export class ReaderView extends ItemView {
452452
}
453453
await this.displayPodcast(item);
454454
} else {
455-
const fullContent = await this.fetchFullArticleContent(item.link);
455+
const fetchedContent = await this.fetchFullArticleContent(item.link);
456+
const fullContent = this.hasMeaningfulArticleContent(fetchedContent)
457+
? fetchedContent
458+
: item.content || item.description || "";
456459
this.currentFullContent = fullContent;
457460
await this.displayArticle(item, fullContent);
458461
}
@@ -550,7 +553,12 @@ export class ReaderView extends ItemView {
550553
this.videoPlayer = null;
551554
}
552555

553-
if (this.webViewerIntegration) {
556+
const shouldUseWebViewer =
557+
Boolean(this.settings.useWebViewer) &&
558+
Boolean(this.webViewerIntegration) &&
559+
!this.shouldBypassWebViewerForFeedContent(item, fullContent);
560+
561+
if (shouldUseWebViewer && this.webViewerIntegration) {
554562
try {
555563
const success = await this.webViewerIntegration.openInWebViewer(
556564
item.link,
@@ -569,6 +577,27 @@ export class ReaderView extends ItemView {
569577
this.renderArticle(item, fullContent);
570578
}
571579

580+
private shouldBypassWebViewerForFeedContent(
581+
item: FeedItem,
582+
fullContent?: string,
583+
): boolean {
584+
if (!item.link) {
585+
return false;
586+
}
587+
588+
const feedHtml = (fullContent || item.content || item.description || "").trim();
589+
if (!feedHtml) {
590+
return false;
591+
}
592+
593+
try {
594+
const host = new URL(item.link).hostname.toLowerCase();
595+
return host === "kite.kagi.com" || host === "news.kagi.com";
596+
} catch {
597+
return false;
598+
}
599+
}
600+
572601
private renderArticle(item: FeedItem, fullContent?: string): void {
573602
const headerContainer = this.readingContainer.createDiv({
574603
cls: "rss-reader-article-header",
@@ -615,42 +644,44 @@ export class ReaderView extends ItemView {
615644
}
616645
}
617646

618-
if (
619-
this.settings.display.showCoverImage &&
620-
(item.coverImage ||
621-
(item.image &&
622-
typeof item.image === "object" &&
623-
(item.image as { url?: string }).url) ||
624-
(typeof item.image === "string" ? item.image : ""))
625-
) {
626-
const imageContainer = this.readingContainer.createDiv({
627-
cls: "rss-reader-cover-image",
628-
});
629-
const coverImg = imageContainer.createEl("img", {
630-
attr: {
631-
src:
632-
(item.coverImage ||
633-
(item.image &&
634-
typeof item.image === "object" &&
635-
(item.image as { url?: string }).url) ||
636-
(typeof item.image === "string" ? item.image : "")) ??
637-
"",
638-
alt: item.title,
639-
},
647+
const descriptionHtml = (item.description || "").trim();
648+
const mainHtml = (fullContent || item.content || "").trim();
649+
650+
if (descriptionHtml) {
651+
const descriptionCallout = this.readingContainer.createEl("details", {
652+
cls: "rss-reader-description-callout",
640653
});
641-
coverImg.addEventListener("error", function () {
642-
this.remove();
654+
descriptionCallout.open = true;
655+
descriptionCallout.createEl("summary", { text: "Feed description" });
656+
const descriptionBody = descriptionCallout.createDiv({
657+
cls: "rss-reader-description rss-reader-description-body",
643658
});
659+
this.populateArticleHtml(descriptionBody, descriptionHtml, item.link);
644660
}
645661

646-
const contentContainer = this.readingContainer.createDiv({
647-
cls: "rss-reader-article-content",
648-
});
662+
const hasDistinctMainContent =
663+
mainHtml && (!descriptionHtml || !this.isEquivalentHtml(mainHtml, descriptionHtml));
649664

650-
const htmlString = ensureUtf8Meta(fullContent || item.description || "");
665+
if (hasDistinctMainContent || !descriptionHtml) {
666+
const contentContainer = this.readingContainer.createDiv({
667+
cls: "rss-reader-article-content",
668+
});
669+
const htmlToRender = hasDistinctMainContent
670+
? mainHtml
671+
: descriptionHtml || mainHtml;
672+
this.populateArticleHtml(contentContainer, htmlToRender, item.link);
673+
}
674+
}
675+
676+
private populateArticleHtml(
677+
container: HTMLElement,
678+
rawHtml: string,
679+
baseUrl: string,
680+
): void {
681+
const htmlString = ensureUtf8Meta(rawHtml || "");
651682
const processedHtmlString = this.convertRelativeUrlsInContent(
652683
htmlString,
653-
item.link,
684+
baseUrl,
654685
);
655686
const parser = new DOMParser();
656687
const doc = parser.parseFromString(processedHtmlString, "text/html");
@@ -661,7 +692,6 @@ export class ReaderView extends ItemView {
661692
parent.appendText(node.textContent || "");
662693
} else if (node.nodeType === Node.ELEMENT_NODE) {
663694
const element = node as HTMLElement;
664-
// Skip icon elements that shouldn't be rendered
665695
const isIconElement =
666696
element.tagName === "I" && element.classList.contains("icon-class");
667697
if (!isIconElement) {
@@ -679,9 +709,9 @@ export class ReaderView extends ItemView {
679709
});
680710
}
681711

682-
appendNodes(contentContainer, doc.body.childNodes);
712+
appendNodes(container, doc.body.childNodes);
683713

684-
contentContainer.querySelectorAll("img").forEach((img) => {
714+
container.querySelectorAll("img").forEach((img) => {
685715
const src = img.getAttribute("src");
686716
if (src && src.startsWith("app://")) {
687717
img.setAttribute("src", src.replace("app://", "https://"));
@@ -693,7 +723,7 @@ export class ReaderView extends ItemView {
693723
});
694724
});
695725

696-
contentContainer.querySelectorAll("source").forEach((source) => {
726+
container.querySelectorAll("source").forEach((source) => {
697727
const srcset = source.getAttribute("srcset");
698728
if (srcset) {
699729
const processedSrcset = srcset
@@ -721,28 +751,47 @@ export class ReaderView extends ItemView {
721751
}
722752
});
723753

724-
contentContainer.querySelectorAll("a").forEach((link) => {
754+
container.querySelectorAll("a").forEach((link) => {
725755
const href = link.getAttribute("href");
726756
if (href && href.startsWith("app://")) {
727757
link.setAttribute("href", href.replace("app://", "https://"));
728758
}
729-
});
730-
731-
this.app.workspace.trigger("parse-math", contentContainer);
732-
733-
const links = contentContainer.querySelectorAll("a");
734-
links.forEach((link) => {
735759
link.setAttribute("target", "_blank");
736760
link.setAttribute("rel", "noopener noreferrer");
737761
});
738762

739-
// Apply word highlighting to content if enabled
763+
this.app.workspace.trigger("parse-math", container);
764+
740765
if (
741766
this.settings.highlights?.enabled &&
742767
this.settings.highlights.highlightInContent
743768
) {
744769
const highlightService = new HighlightService(this.settings.highlights);
745-
highlightService.highlightElement(contentContainer);
770+
highlightService.highlightElement(container);
771+
}
772+
}
773+
774+
private isEquivalentHtml(first: string, second: string): boolean {
775+
if (!first || !second) {
776+
return false;
777+
}
778+
779+
try {
780+
const parser = new DOMParser();
781+
const firstDoc = parser.parseFromString(ensureUtf8Meta(first), "text/html");
782+
const secondDoc = parser.parseFromString(
783+
ensureUtf8Meta(second),
784+
"text/html",
785+
);
786+
const firstText = (firstDoc.body.textContent || "")
787+
.replace(/\s+/g, " ")
788+
.trim();
789+
const secondText = (secondDoc.body.textContent || "")
790+
.replace(/\s+/g, " ")
791+
.trim();
792+
return firstText.length > 0 && firstText === secondText;
793+
} catch {
794+
return first.trim() === second.trim();
746795
}
747796
}
748797

@@ -761,6 +810,36 @@ export class ReaderView extends ItemView {
761810
}
762811
}
763812

813+
private hasMeaningfulArticleContent(content: string): boolean {
814+
if (!content || !content.trim()) {
815+
return false;
816+
}
817+
818+
try {
819+
const parser = new DOMParser();
820+
const doc = parser.parseFromString(content, "text/html");
821+
const body = doc.body;
822+
if (!body) {
823+
return false;
824+
}
825+
826+
body.querySelectorAll("script, style, noscript").forEach((node) => {
827+
node.remove();
828+
});
829+
830+
const text = (body.textContent || "").replace(/\s+/g, " ").trim();
831+
if (text.length >= 80) {
832+
return true;
833+
}
834+
835+
return Boolean(
836+
body.querySelector("p, article, li, blockquote, h1, h2, h3, h4"),
837+
);
838+
} catch {
839+
return content.trim().length >= 80;
840+
}
841+
}
842+
764843
private convertHtmlToMarkdown(html: string): string {
765844
return this.turndownService.turndown(html);
766845
}

styles.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)