diff --git a/internal/handler/social.go b/internal/handler/social.go index 0c91906..c88ae83 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -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) diff --git a/internal/model/social.go b/internal/model/social.go index 3af5dd1..4e94182 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -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"` diff --git a/internal/repository/collection_repo.go b/internal/repository/collection_repo.go index 1f1a3de..86e2410 100644 --- a/internal/repository/collection_repo.go +++ b/internal/repository/collection_repo.go @@ -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) } diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 37f9649..5ee6fe0 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 44ad1df..a0ac409 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 5eca4d4..85c187c 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -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//.. 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 diff --git a/migrations/0037_add_post_is_hidden.down.sql b/migrations/0037_add_post_is_hidden.down.sql new file mode 100644 index 0000000..a676c86 --- /dev/null +++ b/migrations/0037_add_post_is_hidden.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS posts_user_created_visible_idx; +ALTER TABLE posts DROP COLUMN IF EXISTS is_hidden; diff --git a/migrations/0037_add_post_is_hidden.up.sql b/migrations/0037_add_post_is_hidden.up.sql new file mode 100644 index 0000000..b831cf2 --- /dev/null +++ b/migrations/0037_add_post_is_hidden.up.sql @@ -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;