feat(posts): in-context anchored feed with bidirectional infinite scroll

Provenance "View post" deep-links to /posts?post_id=X, which now opens the
feed centered on that post with infinite load in BOTH directions.

Backend: PostFeedService.scroll gains a direction (older|newer); new
around(post_id) returns a window of newer + the post + older with a cursor
for each end. /api/posts accepts ?around= and ?direction=. + API tests.

Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends,
newer prepends) with per-end cursors; PostsView's anchored mode scrolls to
the post, observes top + bottom sentinels, and preserves scroll position on
upward prepend so the page doesn't jump. Normal feed mode unchanged.

Closes the remaining half of the post-navigation work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:12:18 -04:00
parent 35fe420701
commit c95b760294
5 changed files with 382 additions and 54 deletions
+24 -3
View File
@@ -18,6 +18,8 @@ async def list_posts():
artist_id_raw = args.get("artist_id")
platform = args.get("platform") or None
limit_raw = args.get("limit", "24")
direction = args.get("direction", "older")
around_raw = args.get("around")
try:
limit = int(limit_raw)
@@ -26,6 +28,16 @@ async def list_posts():
if limit < 1 or limit > 100:
return _bad("invalid_limit", detail="limit must be between 1 and 100")
if direction not in ("older", "newer"):
return _bad("invalid_direction", detail="direction must be 'older' or 'newer'")
around_id = None
if around_raw is not None:
try:
around_id = int(around_raw)
except ValueError:
return _bad("invalid_around", detail="around must be an integer post id")
artist_id = None
if artist_id_raw is not None:
try:
@@ -40,11 +52,20 @@ async def list_posts():
)
async with get_session() as session:
try:
page = await PostFeedService(session).scroll(
cursor=cursor, artist_id=artist_id,
svc = PostFeedService(session)
if around_id is not None:
result = await svc.around(
post_id=around_id, artist_id=artist_id,
platform=platform, limit=limit,
)
if result is None:
return _bad("not_found", status=404, detail=f"post id={around_id}")
return jsonify(result)
try:
page = await svc.scroll(
cursor=cursor, artist_id=artist_id,
platform=platform, limit=limit, direction=direction,
)
except ValueError as exc:
# Service raises ValueError for malformed cursors only;
# limit bounds are validated above.
+76 -10
View File
@@ -56,9 +56,17 @@ class PostFeedService:
artist_id: int | None = None,
platform: str | None = None,
limit: int = 24,
direction: str = "older",
) -> dict:
"""Paginate the feed from `cursor`. direction='older' walks back in
time (default, infinite-scroll down); direction='newer' walks forward
(scroll up in an anchored view). Items are always returned in feed
(descending) order; `next_cursor` points to the far edge in the
requested direction (null when exhausted)."""
if limit < 1 or limit > 100:
raise ValueError("limit must be between 1 and 100")
if direction not in ("older", "newer"):
raise ValueError("direction must be 'older' or 'newer'")
sort_key = _sort_key()
stmt = (
@@ -72,22 +80,37 @@ class PostFeedService:
stmt = stmt.where(Source.platform == platform)
if cursor:
cur_ts, cur_id = decode_cursor(cursor)
stmt = stmt.where(
or_(
if direction == "older":
stmt = stmt.where(or_(
sort_key < cur_ts,
and_(sort_key == cur_ts, Post.id < cur_id),
)
)
))
else:
stmt = stmt.where(or_(
sort_key > cur_ts,
and_(sort_key == cur_ts, Post.id > cur_id),
))
stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1)
if direction == "older":
stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
else:
stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
stmt = stmt.limit(limit + 1)
rows = (await self.session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
if direction == "newer":
# Fetched ascending (closest-newer first); flip to feed order.
rows = list(reversed(rows))
next_cursor: str | None = None
if len(rows) > limit:
last_post, _, _ = rows[limit - 1]
last_key = last_post.post_date or last_post.downloaded_at
next_cursor = encode_cursor(last_key, last_post.id)
rows = rows[:limit]
if has_more and rows:
# Far edge in the travel direction: oldest row going older,
# newest row going newer (rows is descending for display).
edge_post = rows[-1][0] if direction == "older" else rows[0][0]
edge_key = edge_post.post_date or edge_post.downloaded_at
next_cursor = encode_cursor(edge_key, edge_post.id)
post_ids = [p.id for p, _, _ in rows]
thumbs_map = await self._thumbnails_for(post_ids)
@@ -99,6 +122,49 @@ class PostFeedService:
]
return {"items": items, "next_cursor": next_cursor}
async def around(
self,
*,
post_id: int,
artist_id: int | None = None,
platform: str | None = None,
limit: int = 12,
) -> dict | None:
"""A window centered on `post_id`: up to `limit` newer posts + the
post + up to `limit` older posts, in feed (descending) order, with a
cursor for each end. Returns None if the post doesn't exist."""
anchor = (await self.session.execute(
select(Post, Artist, Source)
.join(Source, Post.source_id == Source.id)
.join(Artist, Source.artist_id == Artist.id)
.where(Post.id == post_id)
)).one_or_none()
if anchor is None:
return None
anchor_post, anchor_artist, anchor_source = anchor
anchor_key = anchor_post.post_date or anchor_post.downloaded_at
anchor_cursor = encode_cursor(anchor_key, anchor_post.id)
older = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="older",
)
newer = await self.scroll(
cursor=anchor_cursor, artist_id=artist_id, platform=platform,
limit=limit, direction="newer",
)
thumbs_map = await self._thumbnails_for([anchor_post.id])
atts_map = await self._attachments_for([anchor_post.id])
anchor_item = self._to_dict(
anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
)
return {
"items": newer["items"] + [anchor_item] + older["items"],
"cursor_older": older["next_cursor"],
"cursor_newer": newer["next_cursor"],
"anchor_id": anchor_post.id,
}
async def get_post(self, post_id: int) -> dict | None:
row = (await self.session.execute(
select(Post, Artist, Source)
+65
View File
@@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => {
const done = ref(false)
const filters = ref({ artist_id: null, platform: null })
// In-context (anchored) view: bidirectional cursors so the feed can load
// newer posts on scroll-up and older posts on scroll-down around a post.
const cursorOlder = ref(null)
const cursorNewer = ref(null)
const doneOlder = ref(false)
const doneNewer = ref(false)
const anchorId = ref(null)
function _qs() {
const q = {}
if (cursor.value) q.cursor = cursor.value
@@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => {
return await api.get(`/api/posts/${id}`)
}
// Load a window centered on `postId`: newer posts above, the post, older
// posts below. Sets both directional cursors for subsequent scrolling.
async function loadAround(postId) {
loading.value = true
error.value = null
anchorId.value = null
try {
const body = await api.get('/api/posts', { params: { around: postId } })
items.value = body.items
cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer
doneOlder.value = body.cursor_older == null
doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorOlder.value, direction: 'older' },
})
items.value.push(...body.items)
cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true
try {
const body = await api.get('/api/posts', {
params: { cursor: cursorNewer.value, direction: 'newer' },
})
items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return {
items, cursor, loading, done, error, filters,
cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId,
loadInitial, loadMore, getPostFull,
loadAround, loadOlder, loadNewer,
}
})
+150 -40
View File
@@ -1,39 +1,84 @@
<template>
<v-container class="pt-2 pb-6" max-width="900">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<!-- In-context view: deep-linked to one post, with bidirectional infinite
scroll newer posts load above, older posts below. -->
<template v-if="postIdFilter != null">
<v-btn
variant="text" size="small" prepend-icon="mdi-arrow-left"
:to="{ name: 'posts' }" class="mb-2"
>All posts</v-btn>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
</div>
<div v-else>
<div ref="topSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneNewer"
indeterminate color="accent" size="20"
/>
<span v-else-if="store.doneNewer" class="fc-posts__end">Top of feed</span>
</div>
<div
v-for="p in store.items" :key="p.id" :id="`fc-post-${p.id}`"
:class="['fc-posts__entry', { 'fc-posts__entry--anchor': p.id === store.anchorId }]"
>
<PostCard :post="p" />
</div>
<div ref="bottomSentinel" class="fc-posts__sentinel">
<v-progress-circular
v-if="store.loading && !store.doneOlder"
indeterminate color="accent" size="24"
/>
<span v-else-if="store.doneOlder" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
<!-- Normal feed -->
<template v-else>
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
@update:filters="onFilters"
/>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && store.items.length === 0" class="fc-posts__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.items.length === 0 && store.done" class="fc-posts__empty">
<p>No posts yet. Subscribe to a source on the
<RouterLink to="/subscriptions">Subscriptions</RouterLink>
tab to start capturing posts.
</p>
</div>
<div v-else>
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
<div ref="sentinel" class="fc-posts__sentinel">
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
</div>
</div>
</template>
</v-container>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePostsStore } from '../stores/posts.js'
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
@@ -48,7 +93,12 @@ const artistFilter = computed(() => {
return raw == null ? null : Number(raw)
})
const platformFilter = computed(() => route.query.platform || null)
const postIdFilter = computed(() => {
const raw = route.query.post_id
return raw == null ? null : Number(raw)
})
// --- normal feed (downward infinite scroll) ---
const sentinel = ref(null)
let observer = null
@@ -58,7 +108,6 @@ async function reload() {
platform: platformFilter.value,
})
}
function onFilters({ artist_id, platform }) {
const q = { ...route.query }
if (artist_id == null) delete q.artist_id
@@ -67,22 +116,79 @@ function onFilters({ artist_id, platform }) {
else q.platform = platform
router.replace({ query: q })
}
watch(() => [artistFilter.value, platformFilter.value], reload)
onMounted(async () => {
await reload()
function teardownFeed() {
if (observer) { observer.disconnect(); observer = null }
}
function setupFeedObserver() {
teardownFeed()
if (!sentinel.value) return
observer = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
store.loadMore()
}
if (entries.some((e) => e.isIntersecting)) store.loadMore()
}, { rootMargin: '400px 0px' })
if (sentinel.value) observer.observe(sentinel.value)
observer.observe(sentinel.value)
}
// --- in-context view (bidirectional, anchored on a post) ---
const topSentinel = ref(null)
const bottomSentinel = ref(null)
let topObs = null
let bottomObs = null
function teardownAround() {
if (topObs) { topObs.disconnect(); topObs = null }
if (bottomObs) { bottomObs.disconnect(); bottomObs = null }
}
function setupAroundObservers() {
teardownAround()
bottomObs = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) store.loadOlder()
}, { rootMargin: '500px 0px' })
if (bottomSentinel.value) bottomObs.observe(bottomSentinel.value)
topObs = new IntersectionObserver(async (entries) => {
if (!entries.some((e) => e.isIntersecting)) return
if (store.loading || store.doneNewer) return
// Preserve the viewport position across an upward prepend so the page
// doesn't jump when newer posts insert above the current view.
const beforeH = document.documentElement.scrollHeight
const beforeY = window.scrollY
await store.loadNewer()
await nextTick()
const afterH = document.documentElement.scrollHeight
window.scrollTo(0, beforeY + (afterH - beforeH))
}, { rootMargin: '200px 0px' })
if (topSentinel.value) topObs.observe(topSentinel.value)
}
async function loadAroundAndAnchor() {
teardownFeed()
await store.loadAround(postIdFilter.value)
await nextTick()
const el = document.getElementById(`fc-post-${store.anchorId}`)
if (el) el.scrollIntoView({ block: 'center' })
setupAroundObservers()
}
async function activate() {
if (postIdFilter.value != null) {
await loadAroundAndAnchor()
} else {
teardownAround()
await reload()
await nextTick()
setupFeedObserver()
}
}
watch(postIdFilter, activate)
watch(() => [artistFilter.value, platformFilter.value], async () => {
if (postIdFilter.value != null) return
await reload()
await nextTick()
setupFeedObserver()
})
onUnmounted(() => {
if (observer) observer.disconnect()
})
onMounted(activate)
onUnmounted(() => { teardownFeed(); teardownAround() })
</script>
<style scoped>
@@ -103,4 +209,8 @@ onUnmounted(() => {
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-posts__entry--anchor :deep(.fc-post-card) {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 3px;
}
</style>
+67 -1
View File
@@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail
endpoint. Service-level fixtures are exercised by test_post_feed_service
— here we focus on the HTTP surface (validation, status codes, dict shape).
"""
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
import pytest
@@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client):
assert body["error"] == "not_found"
@pytest.fixture
async def post_timeline(db):
"""Five posts on distinct dates: posts[0] oldest … posts[4] newest."""
artist = Artist(name="tl-api", slug="tl-api")
db.add(artist)
await db.flush()
source = Source(
artist_id=artist.id, platform="patreon",
url="https://p/tl-api", enabled=True,
)
db.add(source)
await db.flush()
base = datetime(2026, 1, 1, tzinfo=UTC)
posts = []
for i in range(5):
p = Post(
source_id=source.id, external_post_id=f"TL{i}",
post_title=f"post {i}", post_date=base + timedelta(days=i),
)
db.add(p)
posts.append(p)
await db.commit()
return artist, source, posts
@pytest.mark.asyncio
async def test_around_returns_window_with_anchor(client, post_timeline):
_, _, posts = post_timeline
anchor = posts[2]
resp = await client.get(f"/api/posts?around={anchor.id}&limit=1")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"}
assert body["anchor_id"] == anchor.id
# limit=1: one newer + anchor + one older, in feed (desc) order.
assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id]
assert body["cursor_older"] is not None # posts[0] still older
assert body["cursor_newer"] is not None # posts[4] still newer
@pytest.mark.asyncio
async def test_around_404_for_unknown(client):
resp = await client.get("/api/posts?around=999999")
assert resp.status_code == 404
assert (await resp.get_json())["error"] == "not_found"
@pytest.mark.asyncio
async def test_direction_newer_walks_forward(client, post_timeline):
_, _, posts = post_timeline
around = await client.get(f"/api/posts?around={posts[1].id}&limit=1")
cursor_newer = (await around.get_json())["cursor_newer"]
assert cursor_newer is not None
resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5")
assert resp.status_code == 200
# Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order.
assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id]
@pytest.mark.asyncio
async def test_rejects_bad_direction(client):
resp = await client.get("/api/posts?direction=sideways")
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "invalid_direction"
@pytest.mark.asyncio
async def test_detail_returns_uncapped_thumbnails(client, db):
"""Feed query caps thumbnails at 6 for previews; detail endpoint