Skip to content
Open
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
129 changes: 94 additions & 35 deletions app-core/Sources/AppCore/Blog/BlogTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import TmbrCore

struct BlogTab: View {

@Environment(\.refreshBlog)
private var refreshBlog

@Preferences(\.selectedLanguages)
private var selectedLanguages

@State
private var searchText = ""

@State
private var showEditor = false

Expand All @@ -19,37 +19,41 @@ struct BlogTab: View {

var body: some View {
NavigationStack {
PostsList(selectedLanguages: selectedLanguages)
.navigationTitle("Blog")
.toolbar {
PostsList(
languages: selectedLanguages,
searchText: searchText
)
.feed(.posts)
.navigationTitle("Blog")
.searchable(text: $searchText, prompt: "Search posts…")
.toolbar {
#if os(iOS)
ToolbarItem(placement: .topBarLeading) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
}
ToolbarItem(placement: .topBarLeading) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
ToolbarItem(placement: .topBarLeading) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
}
ToolbarItem(placement: .topBarLeading) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
}
#else
ToolbarItem(placement: .automatic) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
.popover(isPresented: $showLanguageFilter) {
LanguageFilterView()
}
ToolbarItem(placement: .automatic) {
Button { showLanguageFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease")
}
ToolbarItem(placement: .automatic) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
.popover(isPresented: $showLanguageFilter) {
LanguageFilterView()
}
}
ToolbarItem(placement: .automatic) {
AuthoringButton(systemImage: "square.and.pencil") { showEditor = true }
}
#endif
ToolbarItem(placement: .primaryAction) {
AccountButton()
}
ToolbarItem(placement: .primaryAction) {
AccountButton()
}
}
}
.task { await refreshBlog() }
#if os(iOS)
.sheet(isPresented: $showLanguageFilter) {
LanguageFilterView()
Expand All @@ -61,16 +65,33 @@ struct BlogTab: View {
}
}

// MARK: - Unified list (browse and search)

private struct PostsList: View {

@Query private var posts: [PostRecord]
@Feed(PostsQuery.self)
private var feed

/// The view's paging state — the source of truth for what to fetch next. Reset on every
/// new intent, updated from the receipt of each committed fetch.
@State
private var page: FeedPage?

@Environment(\.refreshBlog)
private var refreshBlog
@Query
private var posts: [PostRecord]

init(selectedLanguages: Set<Language>) {
private let languages: Set<Language>

private let searchText: String

init(
languages: Set<Language>,
searchText: String
) {
self.languages = languages
self.searchText = searchText
_posts = Query(
filter: PostRecord.list(languages: selectedLanguages),
filter: PostRecord.blog(term: searchText, languages: languages),
sort: \PostRecord.createdAt,
order: .reverse
)
Expand All @@ -79,7 +100,7 @@ private struct PostsList: View {
var body: some View {
List {
if !posts.isEmpty {
BlogStatusLine()
FeedStatusLine()
}
ForEach(posts) { post in
NavigationLink(value: post) {
Expand All @@ -88,14 +109,19 @@ private struct PostsList: View {
date: post.publishedAt ?? post.createdAt
)
}
.onAppear { [createdAt = post.createdAt] in
Task { await fetch(after: createdAt) }
}
}
if !posts.isEmpty {
BlogLoadMoreCell()
FeedLoadMoreCell()
}
}
.overlay {
if posts.isEmpty {
BlogEmptyView()
FeedEmptyView {
ContentUnavailableView("No posts yet", systemImage: "doc.text")
}
}
}
.navigationDestination(for: PostRecord.self) { post in
Expand All @@ -106,6 +132,39 @@ private struct PostsList: View {
published: post.publishedAt
)
}
.refreshable { await refreshBlog() }
.refreshable {
await fetch(page: nil)
}
// Runs on appear and restarts on any term or filter change (the id is the page-1
// query) — the model supersedes in-flight requests, so the last input wins.
.task(id: query()) {
await fetch(page: nil)
}
}

/// Fetches for the given paging state — `nil` is page 1 — and folds the receipt back in.
/// The `?? page` fallback is the failure policy: a failed page 1 falls back to `nil`
/// (no dead sessions), while a failed next page falls back to its own pre-call state,
/// so a later row appearance retries it.
private func fetch(page: FeedPage?) async {
self.page = await feed.fetch(query(page: page)) ?? page
}

/// Row-driven paging: a row at or past the fetched frontier is unverified local cache —
/// fetch the next page for it. The `nextCursor` check gates exhaustion: without it, an
/// exhausted page would build a cursor-less query and restart the session as page 1.
private func fetch(after createdAt: Date) async {
guard let page, page.nextCursor != nil,
let frontier = page.frontier,
createdAt <= frontier else { return }
await fetch(page: page)
}

private func query(page: FeedPage? = nil) -> PostsQuery {
PostsQuery(
cursor: page?.nextCursor,
languages: languages,
term: searchText
)
}
}
16 changes: 14 additions & 2 deletions app-persistence/Sources/AppPersistence/PostRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,22 @@ public extension PostRecord {
/// Predicate for in-tab text search. Matches posts whose title or content contains `term`
/// (case-insensitive, locale-aware), filtered by the active language selection.
static func search(term: String, languages: Set<Language>) -> Predicate<PostRecord> {
let codes = languages.map(\.rawValue)
let list = Self.list(languages: languages)
return #Predicate<PostRecord> { post in
(post.title.localizedStandardContains(term) || post.content.localizedStandardContains(term)) &&
(codes.isEmpty || post.languageRaw.isEmpty || codes.contains(post.languageRaw))
list.evaluate(post)
}
}

/// Predicate for the blog list — text search when `term` is non-empty, plain list
/// otherwise; the language filter always applies. Mirrors what the server returns for the
/// same term + languages, so one pair of inputs drives both the local `@Query` and the
/// network fetch.
static func blog(term: String, languages: Set<Language>) -> Predicate<PostRecord> {
if !term.isEmpty {
search(term: term, languages: languages)
} else {
list(languages: languages)
}
}
}