feat: add feed
This commit is contained in:
@@ -213,6 +213,103 @@ func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// FeedForViewer returns one page of the global home feed for `viewerID`,
|
||||
// newest-first within a two-tier priority:
|
||||
//
|
||||
// tier 0 — posts by people the viewer follows that they haven't liked yet
|
||||
// tier 1 — everything else (followed-and-liked, followers', strangers')
|
||||
//
|
||||
// The viewer's own posts and anything involving a block (either direction)
|
||||
// are excluded. The tier is computed in ORDER BY via correlated EXISTS, so no
|
||||
// extra columns leak into scanPost. No total is computed (infinite scroll
|
||||
// infers "more" from a full page); offset paging keeps it simple. At larger
|
||||
// scale this would move to a precomputed fan-out / keyset cursor.
|
||||
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) {
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
|
||||
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
|
||||
)
|
||||
ORDER BY
|
||||
(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = p.user_id
|
||||
AND f.status = 'accepted'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM post_likes pl
|
||||
WHERE pl.post_id = p.id AND pl.user_id = $1
|
||||
)
|
||||
THEN 0 ELSE 1
|
||||
END) ASC,
|
||||
p.created_at DESC, p.id DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset)
|
||||
return posts, err
|
||||
}
|
||||
|
||||
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer
|
||||
// across a page of feed posts in two batched queries (one per direction).
|
||||
// No-op for anonymous viewers.
|
||||
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if viewerID == "" || len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
authorIDs := make([]string, 0, len(posts))
|
||||
for i := range posts {
|
||||
id := posts[i].UserID
|
||||
if id != "" && id != viewerID && !seen[id] {
|
||||
seen[id] = true
|
||||
authorIDs = append(authorIDs, id)
|
||||
}
|
||||
}
|
||||
if len(authorIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
following, err := r.followEdges(ctx,
|
||||
`SELECT following_id FROM follows
|
||||
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
|
||||
viewerID, authorIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followers, err := r.followEdges(ctx,
|
||||
`SELECT follower_id FROM follows
|
||||
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
|
||||
viewerID, authorIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range posts {
|
||||
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
||||
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// followEdges runs a one-column follow query and collects the ids into a set.
|
||||
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydrate follow edges: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan follow edge: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||
// - the post's images (post_images.post_id FK)
|
||||
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||
|
||||
@@ -379,6 +379,47 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]m
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
|
||||
// newest first, as a discovery list for the feed's "new on the platform"
|
||||
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
|
||||
// (pending or accepted), and anyone involved in a block either direction — so
|
||||
// every suggestion is a fresh account the viewer can actually follow.
|
||||
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
|
||||
if limit <= 0 || limit > 20 {
|
||||
limit = 5
|
||||
}
|
||||
const q = `
|
||||
SELECT u.id, u.handle, u.name, u.avatar_url
|
||||
FROM users u
|
||||
WHERE u.created_at >= $2
|
||||
AND u.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = $1 AND b.blocked_id = u.id)
|
||||
OR (b.blocker_id = u.id AND b.blocked_id = $1)
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $3`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, viewerID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recently joined: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.UserSummary{}
|
||||
for rows.Next() {
|
||||
var u model.UserSummary
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.Name, &u.AvatarURL); err != nil {
|
||||
return nil, fmt.Errorf("scan recently joined: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
||||
// prefs (empty object when never set). The frontend owns the shape, so
|
||||
// we round-trip the payload verbatim — no server-side schema enforced.
|
||||
|
||||
Reference in New Issue
Block a user