perf: slim entries API + on-demand article loading for iOS Reader
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 19:43:38 -05:00
parent a3eabf3e3b
commit 8b0987bcac
2 changed files with 69 additions and 9 deletions

View File

@@ -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
}

View File

@@ -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],
)