fix: hide post

This commit is contained in:
Krispire Admin
2026-06-14 19:51:57 +05:30
parent 93e92c610c
commit 438258ade0
8 changed files with 176 additions and 8 deletions
+47 -1
View File
@@ -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)
+5
View File
@@ -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"`
+2 -2
View File
@@ -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)
}
+57 -5
View File
@@ -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
+4
View File
@@ -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
+48
View File
@@ -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