fix: hide post
This commit is contained in:
@@ -234,6 +234,27 @@ func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Hide — POST /posts/{id}/hide. Owner hides the post from timelines + public
|
||||
// collections.
|
||||
func (h *PostHandler) Hide(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, true); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Unhide — DELETE /posts/{id}/hide. Restores the post to timelines.
|
||||
func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, false); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
@@ -245,6 +266,19 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// ListHidden — GET /posts/hidden. The caller's hidden posts (auth required),
|
||||
// for the /u/me "Hidden" tab where they can unhide them.
|
||||
func (h *PostHandler) ListHidden(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
posts, total, err := h.svc.ListHidden(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// Feed — GET /feed?limit=&offset=. The global personalised home feed (auth
|
||||
// required). Returns at most `limit` posts; the client infers "more" from a
|
||||
// full page (no total count for an unbounded global feed).
|
||||
@@ -602,6 +636,16 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||
return
|
||||
}
|
||||
// Hidden-post count is meaningful only to the owner (drives the /u/me
|
||||
// "Hidden" tab). Compute it just for self; 0 for everyone else.
|
||||
hiddenCount := 0
|
||||
if middleware.UserID(r.Context()) == u.ID {
|
||||
hiddenCount, err = h.posts.CountHiddenByUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load collections")
|
||||
@@ -625,6 +669,7 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
"joined_at": u.CreatedAt,
|
||||
"country": u.Country,
|
||||
"post_count": count,
|
||||
"hidden_count": hiddenCount,
|
||||
"follower_count": followers,
|
||||
"following_count": following,
|
||||
"relationship": rel,
|
||||
@@ -672,7 +717,8 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit, offset := parseLimitOffset(r)
|
||||
// gateByBlock already resolved the owner, so list by id rather than
|
||||
// re-resolving the handle.
|
||||
// re-resolving the handle. Hidden posts never appear here (they live in
|
||||
// the owner's separate Hidden tab).
|
||||
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
|
||||
@@ -20,6 +20,11 @@ type Post struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// IsHidden — owner has hidden this post from timelines (their profile as
|
||||
// seen by others, the home feed) and from public collections. It still
|
||||
// shows to the owner on their own profile and in private collections.
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
|
||||
// Author info — populated by repo joins for feed listings.
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
AuthorHandle string `json:"author_handle,omitempty"`
|
||||
|
||||
@@ -207,7 +207,7 @@ func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, user
|
||||
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url, p.is_hidden
|
||||
FROM collection_items ci
|
||||
JOIN posts p ON p.id = ci.post_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
@@ -227,7 +227,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.IsHidden,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ const postSelectColumns = `
|
||||
p.reshared_from_post_id,
|
||||
ou.name AS original_author_name,
|
||||
ou.handle AS original_author_handle,
|
||||
ou.avatar_url AS original_author_avatar
|
||||
ou.avatar_url AS original_author_avatar,
|
||||
p.is_hidden
|
||||
`
|
||||
|
||||
const postSelectFrom = `
|
||||
@@ -58,6 +59,7 @@ func scanPost(row interface {
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
&p.ResharedFromPostID,
|
||||
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
|
||||
&p.IsHidden,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,16 +184,18 @@ func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, li
|
||||
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
||||
}
|
||||
|
||||
// Hidden posts are dropped from the mentions timeline too (the mentioned
|
||||
// user is not the author, so there is no owner exception here).
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1`,
|
||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE`,
|
||||
userID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
JOIN post_mentions pm ON pm.post_id = p.id
|
||||
WHERE pm.user_id = $1
|
||||
WHERE pm.user_id = $1 AND p.is_hidden = FALSE
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
@@ -201,13 +205,30 @@ func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, li
|
||||
// authored or reshared. Reshares are interleaved naturally because
|
||||
// they're stored as real rows; sort by created_at gets the right order
|
||||
// without any UNION gymnastics.
|
||||
//
|
||||
// Hidden posts are excluded from this timeline for everyone, including the
|
||||
// owner — they live in the owner's separate "Hidden" list (ListHiddenByUser).
|
||||
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
total, err := r.CountByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
||||
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
|
||||
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
total, err := r.CountHiddenByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = TRUE
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
@@ -239,6 +260,7 @@ func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, of
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id <> $1
|
||||
AND p.is_hidden = FALSE
|
||||
AND p.id <> ALL($4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
@@ -339,12 +361,29 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetHidden flips a post's hidden flag. Owner-only: the WHERE clause scopes
|
||||
// to user_id, so a non-owner (or unknown id) affects no rows → ErrPostNotFound.
|
||||
func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden bool) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE posts SET is_hidden = $3, updated_at = now() WHERE id = $1 AND user_id = $2`,
|
||||
postID, userID, hidden)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set post hidden: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser returns the user's total post count (authored + reshared).
|
||||
// Now just a single COUNT against posts.user_id.
|
||||
// CountByUser counts the user's visible (non-hidden) posts — matches the
|
||||
// timeline shown on their profile.
|
||||
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count posts: %w", err)
|
||||
@@ -352,6 +391,19 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountHiddenByUser counts the owner's hidden posts (drives the "Hidden" tab's
|
||||
// visibility + badge).
|
||||
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count hidden posts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PostedDates returns the set of UTC calendar days, on or after `since`,
|
||||
// on which the user created at least one post. Used by the sādhana streak
|
||||
// to detect the "posted a quote today" discipline (and to score past days
|
||||
|
||||
@@ -681,8 +681,12 @@ func (s *Server) setupRouter() {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/", s.postH.Create)
|
||||
r.Delete("/{id}", s.postH.Delete)
|
||||
// Hide / unhide from timelines + public collections (owner-only).
|
||||
r.Post("/{id}/hide", s.postH.Hide)
|
||||
r.Delete("/{id}/hide", s.postH.Unhide)
|
||||
r.Put("/{id}/images", s.postH.ReplaceImages)
|
||||
r.Get("/mine", s.postH.ListMine)
|
||||
r.Get("/hidden", s.postH.ListHidden)
|
||||
r.Post("/image-presign", s.postH.ImagePresign)
|
||||
// Reshare is auth-required: anonymous users can't claim
|
||||
// to have reshared anything. The action is idempotent so
|
||||
|
||||
@@ -174,6 +174,12 @@ func (s *PostService) Delete(ctx context.Context, postID, callerID string) error
|
||||
return s.posts.Delete(ctx, postID, callerID)
|
||||
}
|
||||
|
||||
// SetHidden hides/unhides a post from timelines + public collections.
|
||||
// Owner-only — the repo scopes the update to the caller.
|
||||
func (s *PostService) SetHidden(ctx context.Context, postID, callerID string, hidden bool) error {
|
||||
return s.posts.SetHidden(ctx, postID, callerID, hidden)
|
||||
}
|
||||
|
||||
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||||
// reshare is its own posts row, owned by the resharer, so this works
|
||||
// uniformly for canonical posts and reshares.
|
||||
@@ -438,6 +444,36 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
||||
// like ListMine so the cards render fully and the menu offers Unhide.
|
||||
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
posts, total, err := s.posts.ListHiddenByUser(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range posts {
|
||||
if posts[i].ResharedFromPostID != nil {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ImagePresign returns a one-shot R2 PUT URL for a post image.
|
||||
// The key is namespaced under post-images/<user_id>/<random>.<ext>. We
|
||||
// don't tie it to a post_id at presign time because the post might not
|
||||
@@ -654,6 +690,18 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Hidden posts never appear in PUBLIC collections. Private collections are
|
||||
// owner-only (gated above) and keep hidden posts so the owner can still
|
||||
// see and manage them there.
|
||||
if c.IsPublic {
|
||||
visible := make([]model.Post, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if !p.IsHidden {
|
||||
visible = append(visible, p)
|
||||
}
|
||||
}
|
||||
posts = visible
|
||||
}
|
||||
// PostsIn returns posts without images (single-table boundary). Hydrate
|
||||
// them in one batched query so callers — and the post cards rendered
|
||||
// inside collection carousels — can show the post's first image as
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS posts_user_created_visible_idx;
|
||||
ALTER TABLE posts DROP COLUMN IF EXISTS is_hidden;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Posts can be hidden from timelines (profile feed as seen by others, the
|
||||
-- global home feed) and from public collections by their owner. Hidden posts
|
||||
-- still appear to the owner on their own profile and in private collections.
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS is_hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Partial index keeps the "visible posts, newest first" timeline/feed scans
|
||||
-- cheap by excluding hidden rows from the index entirely.
|
||||
CREATE INDEX IF NOT EXISTS posts_user_created_visible_idx
|
||||
ON posts (user_id, created_at DESC)
|
||||
WHERE is_hidden = FALSE;
|
||||
Reference in New Issue
Block a user