perf: slim entries API + on-demand article loading for iOS Reader
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:
@@ -7,6 +7,8 @@ struct ArticleView: View {
|
|||||||
@State private var isFetchingFull = false
|
@State private var isFetchingFull = false
|
||||||
@State private var savedToBrain = false
|
@State private var savedToBrain = false
|
||||||
@State private var webViewHeight: CGFloat = 400
|
@State private var webViewHeight: CGFloat = 400
|
||||||
|
@State private var isContentReady = false
|
||||||
|
@State private var articleContent = ""
|
||||||
|
|
||||||
init(entry: ReaderEntry, vm: ReaderViewModel) {
|
init(entry: ReaderEntry, vm: ReaderViewModel) {
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
@@ -53,7 +55,17 @@ struct ArticleView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Article body
|
// 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 {
|
if isFetchingFull {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -82,7 +94,7 @@ struct ArticleView: View {
|
|||||||
.padding(.vertical, 40)
|
.padding(.vertical, 40)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ArticleWebView(html: wrapHTML(currentEntry.articleHTML), contentHeight: $webViewHeight)
|
ArticleWebView(html: wrapHTML(articleContent), contentHeight: $webViewHeight)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +154,22 @@ struct ArticleView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
// Auto-mark as read
|
// Auto-mark as read (fire-and-forget)
|
||||||
await vm.markAsRead(entry)
|
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 {
|
do {
|
||||||
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
|
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
|
||||||
currentEntry = updated
|
currentEntry = updated
|
||||||
|
articleContent = updated.articleHTML
|
||||||
if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) {
|
if let idx = vm.entries.firstIndex(where: { $0.id == updated.id }) {
|
||||||
vm.entries[idx] = updated
|
vm.entries[idx] = updated
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,13 +47,28 @@ class EntryOut(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_entry(cls, entry: Entry) -> "EntryOut":
|
def from_entry(cls, entry: Entry, slim: bool = False) -> "EntryOut | EntrySlimOut":
|
||||||
# Use full_content if available, otherwise RSS content
|
|
||||||
best_content = entry.full_content if entry.full_content else entry.content
|
|
||||||
# Extract thumbnail from stored field, or from content
|
# Extract thumbnail from stored field, or from content
|
||||||
thumb = entry.thumbnail
|
thumb = entry.thumbnail
|
||||||
if not thumb:
|
if not thumb:
|
||||||
thumb = cls._extract_thumbnail(entry.content or entry.full_content or "")
|
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(
|
return cls(
|
||||||
id=entry.id,
|
id=entry.id,
|
||||||
title=entry.title,
|
title=entry.title,
|
||||||
@@ -85,9 +100,26 @@ class EntryOut(BaseModel):
|
|||||||
return None
|
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):
|
class EntryListOut(BaseModel):
|
||||||
total: int
|
total: int
|
||||||
entries: list[EntryOut]
|
entries: list[EntryOut | EntrySlimOut]
|
||||||
|
|
||||||
|
|
||||||
class EntryBulkUpdate(BaseModel):
|
class EntryBulkUpdate(BaseModel):
|
||||||
@@ -104,6 +136,7 @@ async def list_entries(
|
|||||||
starred: Optional[bool] = Query(None),
|
starred: Optional[bool] = Query(None),
|
||||||
feed_id: Optional[int] = Query(None),
|
feed_id: Optional[int] = Query(None),
|
||||||
category_id: Optional[int] = Query(None),
|
category_id: Optional[int] = Query(None),
|
||||||
|
slim: bool = Query(True),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
direction: str = Query("desc"),
|
direction: str = Query("desc"),
|
||||||
@@ -149,7 +182,7 @@ async def list_entries(
|
|||||||
|
|
||||||
return EntryListOut(
|
return EntryListOut(
|
||||||
total=total,
|
total=total,
|
||||||
entries=[EntryOut.from_entry(e) for e in entries],
|
entries=[EntryOut.from_entry(e, slim=slim) for e in entries],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user