From 8a8ce2447903c3fb959c5245fa02ef322b34e80e Mon Sep 17 00:00:00 2001
From: gosangam
Date: Sun, 31 May 2026 14:54:48 +0530
Subject: [PATCH] fix: reshared posts should be shown in feed
---
app/feedback/page.tsx | 180 +++++++++++++++++++++++++
app/page.tsx | 10 +-
components/profile/profile-header.tsx | 36 ++---
components/social/post-card.tsx | 12 +-
components/social/post-scroll-card.tsx | 12 +-
lib/social.ts | 37 +++++
6 files changed, 267 insertions(+), 20 deletions(-)
create mode 100644 app/feedback/page.tsx
diff --git a/app/feedback/page.tsx b/app/feedback/page.tsx
new file mode 100644
index 00000000..d497bdfc
--- /dev/null
+++ b/app/feedback/page.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+/**
+ * /feedback — single-page form for bug reports, feature requests, and
+ * general comments. Submissions persist to the BFF and trigger an
+ * admin notification email. No follow-up UI beyond a success toast
+ * and a redirect back to /.
+ *
+ * Authed visitors don't see the contact-email field — the BFF reads
+ * their account email from the session cookie. Anonymous visitors get
+ * the field and must fill it in so we have a reply path.
+ */
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { ArrowLeft, MessageSquare, Send } from "lucide-react";
+import { toast } from "sonner";
+import { feedback as feedbackAPI, SocialError } from "@/lib/social";
+import { useAuth } from "@/components/auth/auth-provider";
+
+type Category = "bug" | "feature" | "general" | "other";
+
+const CATEGORIES: { value: Category; label: string; description: string }[] = [
+ { value: "general", label: "General feedback", description: "Thoughts, impressions, suggestions — anything else." },
+ { value: "bug", label: "Bug report", description: "Something is broken or behaving unexpectedly." },
+ { value: "feature", label: "Feature request", description: "An idea for something new on the site." },
+ { value: "other", label: "Other", description: "Doesn't fit the above? Use this." },
+];
+
+export default function FeedbackPage() {
+ const router = useRouter();
+ const { user, loading } = useAuth();
+ const authed = !loading && !!user;
+
+ const [category, setCategory] = useState("general");
+ const [message, setMessage] = useState("");
+ const [contactEmail, setContactEmail] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ const trimmed = message.trim();
+ if (!trimmed) {
+ setError("Message is required.");
+ return;
+ }
+ if (!authed && !contactEmail.trim()) {
+ setError("Email is required so we can reply.");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await feedbackAPI.submit({
+ category,
+ message: trimmed,
+ contact_email: authed ? undefined : contactEmail.trim(),
+ });
+ toast.success("Thanks for the feedback!", {
+ description: "We've forwarded it to the team.",
+ duration: 5000,
+ });
+ setMessage("");
+ setContactEmail("");
+ router.push("/");
+ } catch (err) {
+ setError(err instanceof SocialError ? err.message : "Could not send feedback.");
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const selected = CATEGORIES.find((c) => c.value === category);
+
+ return (
+
+
+ Back to home
+
+
+
+
+
+
+
+
+ Send feedback
+
+
+ Tell us what's working, what's broken, or what you'd like to see next. We read every message.
+
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 0d5b07d4..4a1ece36 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
-import { Compass, LogIn, Sparkles } from "lucide-react";
+import { Compass, LogIn, MessageSquare, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
/**
@@ -78,6 +78,14 @@ export default function HomePage() {
— it's free.
) : null}
+
+
+
+ Send feedback
+
);
diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx
index 545bc571..bf5e3c2a 100644
--- a/components/profile/profile-header.tsx
+++ b/components/profile/profile-header.tsx
@@ -8,22 +8,24 @@
* The action row hosts:
* • "Edit profile" link — only on the owner's own profile (via the
* `editHref` prop set by /u/me layout).
- * • Reading-settings gear — for every signed-in viewer. It links to
- * /u/me/settings (a focused page that mounts ReadingSettings in
- * inline mode) rather than opening a popover here. A popover
- * triggered from this centred row tends to spill off the left edge
- * on narrow viewports, so the page-navigation pattern matches Edit
- * Profile and dodges the problem entirely.
+ * • Reading-settings gear — also owner-only. We use `editHref` as the
+ * "is this my profile?" signal (the /u/me layout passes it; the
+ * /u/[handle] layout does not), so the two buttons stay in lockstep
+ * and visitors on someone else's profile see a clean row with no
+ * personal-settings affordances. The gear links to /u/me/settings
+ * (the inline reading-prefs page) rather than opening a popover —
+ * centred-row popovers tend to spill off the left edge on narrow
+ * viewports.
*
- * Anonymous visitors don't see this row at all — they read SiteHeader's
- * popover gear instead.
+ * Auth users on someone else's profile reach reading settings via the
+ * sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile
+ * gear.
*/
import Image from "next/image";
import Link from "next/link";
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
-import { useAuth } from "@/components/auth/auth-provider";
export function ProfileHeader({
avatarURL,
@@ -38,16 +40,16 @@ export function ProfileHeader({
handle: string;
postCount?: number;
joinedAt?: string;
- /** When provided, shows an "Edit profile" CTA below the handle. */
+ /** When provided, shows an "Edit profile" CTA below the handle AND
+ * the reading-settings gear. Acts as the canonical "this is my own
+ * profile" signal across the action row. */
editHref?: string;
}) {
- const { user, loading } = useAuth();
- // ReadingSettings appears for every authenticated viewer (own profile
- // OR someone else's), so an auth user reading a friend's profile can
- // still reach theme/palette/font controls. Anonymous viewers fall back
- // to SiteHeader's mobile gear.
- const showReadingSettings = !loading && !!user;
- const showActions = !!editHref || showReadingSettings;
+ // Both action-row affordances are owner-only and ride on the same
+ // `editHref` signal — when it's absent we render no action row at
+ // all, which keeps visitor profile views clean.
+ const showReadingSettings = !!editHref;
+ const showActions = !!editHref;
return (
diff --git a/components/social/post-card.tsx b/components/social/post-card.tsx
index b153ab55..09008024 100644
--- a/components/social/post-card.tsx
+++ b/components/social/post-card.tsx
@@ -22,7 +22,7 @@
import Image from "next/image";
import Link from "next/link";
-import { ExternalLink } from "lucide-react";
+import { ExternalLink, Repeat2 } from "lucide-react";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
@@ -46,6 +46,16 @@ export function PostCard({
return (
+ {/* Reshare attribution — only on rows the feed owner reshared.
+ The author block below still shows the original author; this
+ strip just signals "this is on this feed because it was
+ reshared," matching the Twitter/X retweet convention. */}
+ {post.reshared_at ? (
+
+
+ Reshared
+
+ ) : null}
{/* Author header — identity on the left, overflow menu on the
right. The menu trigger only renders when there are items to
show (delete for owners, block/report for everyone else), so
diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx
index e882c4a1..d4aed2c7 100644
--- a/components/social/post-scroll-card.tsx
+++ b/components/social/post-scroll-card.tsx
@@ -29,7 +29,7 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
-import { ImageIcon } from "lucide-react";
+import { ImageIcon, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
@@ -75,6 +75,16 @@ export function PostScrollCard({
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
>
+ {/* Reshare attribution — only on rows the feed owner reshared.
+ The author block below still names the original author; this
+ strip just signals why the post is in this feed. Matches the
+ Twitter/X retweet convention. */}
+ {post.reshared_at ? (
+
+
+ Reshared
+
+ ) : null}
{/* Author header — identity on the left, three-dot overflow menu
on the right (Delete for author, Block/Report for everyone
else; PostCardMenu hides itself when there are no items). */}
diff --git a/lib/social.ts b/lib/social.ts
index 96e04a1d..c2017609 100644
--- a/lib/social.ts
+++ b/lib/social.ts
@@ -67,6 +67,11 @@ export type Post = {
* collection contents). Always false / absent for anonymous viewers.
* Drives the post-card "Reshare ↔ Send" swap. */
viewer_has_reshared?: boolean;
+ /** Non-null only on rows that appear in a profile feed because the
+ * profile owner reshared them. Drives the "↻ Reshared" header in
+ * the post card and the COALESCE(reshared_at, created_at) sort on
+ * the BFF. Authored posts leave this null. */
+ reshared_at?: string | null;
};
export type Bookmark = {
@@ -100,6 +105,15 @@ export type PublicProfile = {
public_collections: Collection[];
};
+export type Feedback = {
+ id: string;
+ user_id?: string;
+ category: string;
+ message: string;
+ contact_email?: string;
+ created_at: string;
+};
+
// ─── Posts ───────────────────────────────────────────────────────────────
export const posts = {
@@ -288,6 +302,29 @@ export const collections = {
},
};
+// ─── Feedback ────────────────────────────────────────────────────────────
+
+export const feedback = {
+ /** Submit a /feedback page entry. When the viewer is signed in the
+ * BFF captures their user_id automatically via the auth cookie —
+ * the form doesn't ask for it. `contact_email` is required only
+ * for anonymous submissions; the BFF re-enforces that on its end. */
+ submit(input: {
+ category: "bug" | "feature" | "general" | "other";
+ message: string;
+ contact_email?: string;
+ }) {
+ return call("/feedback", {
+ method: "POST",
+ body: JSON.stringify({
+ category: input.category,
+ message: input.message,
+ contact_email: input.contact_email || undefined,
+ }),
+ });
+ },
+};
+
// ─── Public profile ──────────────────────────────────────────────────────
export const profile = {