From 8b0987bcac7290dd3f438b8718e0c66383409892 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 19:43:38 -0500 Subject: [PATCH] perf: slim entries API + on-demand article loading for iOS Reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: add slim query param (default true) to entries list endpoint, returning EntrySlimOut without content/full_content HTML — cuts payload size dramatically for list views. Single entry endpoint still returns full content. iOS: ArticleView now fetches full entry content on demand when opened instead of relying on list data. Shows loading indicator while fetching. Mark-as-read is fire-and-forget to avoid blocking the view. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Reader/Views/ArticleView.swift | 35 +++++++++++++-- services/reader/app/api/entries.py | 43 ++++++++++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index 8de0cc2..9d591c9 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -7,6 +7,8 @@ struct ArticleView: View { @State private var isFetchingFull = false @State private var savedToBrain = false @State private var webViewHeight: CGFloat = 400 + @State private var isContentReady = false + @State private var articleContent = "" init(entry: ReaderEntry, vm: ReaderViewModel) { self.entry = entry @@ -53,7 +55,17 @@ struct ArticleView: View { .padding(.horizontal, 16) // Article body - if currentEntry.articleHTML.isEmpty { + if !isContentReady { + HStack { + ProgressView() + .controlSize(.small) + Text("Loading article...") + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if articleContent.isEmpty { if isFetchingFull { HStack { ProgressView() @@ -82,7 +94,7 @@ struct ArticleView: View { .padding(.vertical, 40) } } else { - ArticleWebView(html: wrapHTML(currentEntry.articleHTML), contentHeight: $webViewHeight) + ArticleWebView(html: wrapHTML(articleContent), contentHeight: $webViewHeight) .frame(height: webViewHeight) } @@ -142,8 +154,22 @@ struct ArticleView: View { } } .task { - // Auto-mark as read - await vm.markAsRead(entry) + // Auto-mark as read (fire-and-forget) + Task { await vm.markAsRead(entry) } + + // Fetch full entry content on demand + do { + let fullEntry = try await ReaderAPI().getEntry(id: currentEntry.id) + currentEntry = fullEntry + articleContent = fullEntry.articleHTML + if let idx = vm.entries.firstIndex(where: { $0.id == fullEntry.id }) { + vm.entries[idx] = fullEntry + } + } catch { + // Fall back to whatever content we already have + articleContent = currentEntry.articleHTML + } + isContentReady = true } } @@ -166,6 +192,7 @@ struct ArticleView: View { do { let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id) currentEntry = updated + articleContent = updated.articleHTML if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) { vm.entries[idx] = updated } diff --git a/services/reader/app/api/entries.py b/services/reader/app/api/entries.py index be3ea9e..05d61f3 100644 --- a/services/reader/app/api/entries.py +++ b/services/reader/app/api/entries.py @@ -47,13 +47,28 @@ class EntryOut(BaseModel): from_attributes = True @classmethod - def from_entry(cls, entry: Entry) -> "EntryOut": - # Use full_content if available, otherwise RSS content - best_content = entry.full_content if entry.full_content else entry.content + def from_entry(cls, entry: Entry, slim: bool = False) -> "EntryOut | EntrySlimOut": # Extract thumbnail from stored field, or from content thumb = entry.thumbnail if not thumb: thumb = cls._extract_thumbnail(entry.content or entry.full_content or "") + + if slim: + return EntrySlimOut( + id=entry.id, + title=entry.title, + url=entry.url, + author=entry.author, + published_at=entry.published_at.isoformat() if entry.published_at else None, + status=entry.status, + starred=entry.starred, + reading_time=entry.reading_time, + thumbnail=thumb, + feed=FeedRef(id=entry.feed.id, title=entry.feed.title) if entry.feed else None, + ) + + # Use full_content if available, otherwise RSS content + best_content = entry.full_content if entry.full_content else entry.content return cls( id=entry.id, title=entry.title, @@ -85,9 +100,26 @@ class EntryOut(BaseModel): return None +class EntrySlimOut(BaseModel): + """Entry without content fields — used for list views.""" + id: int + title: str | None = None + url: str | None = None + author: str | None = None + published_at: str | None = None + status: str = "unread" + starred: bool = False + reading_time: int = 1 + thumbnail: str | None = None + feed: FeedRef | None = None + + class Config: + from_attributes = True + + class EntryListOut(BaseModel): total: int - entries: list[EntryOut] + entries: list[EntryOut | EntrySlimOut] class EntryBulkUpdate(BaseModel): @@ -104,6 +136,7 @@ async def list_entries( starred: Optional[bool] = Query(None), feed_id: Optional[int] = Query(None), category_id: Optional[int] = Query(None), + slim: bool = Query(True), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), direction: str = Query("desc"), @@ -149,7 +182,7 @@ async def list_entries( return EntryListOut( total=total, - entries=[EntryOut.from_entry(e) for e in entries], + entries=[EntryOut.from_entry(e, slim=slim) for e in entries], )