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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user