fix: reshared posts should be shown in feed

This commit is contained in:
gosangam
2026-05-31 14:54:48 +05:30
parent 7d76afe102
commit 8a8ce24479
6 changed files with 267 additions and 20 deletions
+180
View File
@@ -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<Category>("general");
const [message, setMessage] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<main className="flex-1 max-w-xl w-full mx-auto px-4 sm:px-6 py-10 sm:py-14">
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" /> Back to home
</Link>
<header className="mt-6 mb-6 flex items-start gap-3">
<span
aria-hidden
className="inline-flex size-11 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
>
<MessageSquare className="size-5" />
</span>
<div>
<h1 className="font-[family-name:var(--font-title)] font-semibold text-2xl text-foreground leading-tight">
Send feedback
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Tell us what&apos;s working, what&apos;s broken, or what you&apos;d like to see next. We read every message.
</p>
</div>
</header>
<form
onSubmit={submit}
className="space-y-5 rounded-2xl bg-card ring-1 ring-foreground/10 p-5"
>
<div>
<label htmlFor="category" className="text-sm font-medium text-foreground">
What&apos;s this about?
</label>
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value as Category)}
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={submitting}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<p className="mt-1 text-xs text-muted-foreground">
{selected?.description}
</p>
</div>
<div>
<label htmlFor="message" className="text-sm font-medium text-foreground">
Message
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Share as much detail as you'd like…"
rows={6}
maxLength={5000}
disabled={submitting}
className="mt-1.5 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
/>
<p className="mt-1 text-xs text-muted-foreground text-right tabular-nums">
{message.length}/5000
</p>
</div>
{!loading && !authed ? (
<div>
<label htmlFor="contact_email" className="text-sm font-medium text-foreground">
Your email
</label>
<input
id="contact_email"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={submitting}
className="mt-1.5 h-10 w-full rounded-lg border border-border bg-card px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="mt-1 text-xs text-muted-foreground">
So we can write back if needed. We don&apos;t add you to a mailing list.
</p>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
type="submit"
disabled={submitting || !message.trim()}
className="w-full h-11 inline-flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground text-base font-medium hover:bg-primary/90 transition-colors disabled:opacity-60"
>
<Send className="size-4" />
{submitting ? "Sending…" : "Send feedback"}
</button>
</form>
</main>
);
}
+9 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; 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"; import { useAuth } from "@/components/auth/auth-provider";
/** /**
@@ -78,6 +78,14 @@ export default function HomePage() {
— it&apos;s free. — it&apos;s free.
</p> </p>
) : null} ) : null}
<Link
href="/feedback"
className="mt-8 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
>
<MessageSquare className="size-3.5" />
Send feedback
</Link>
</div> </div>
</main> </main>
); );
+19 -17
View File
@@ -8,22 +8,24 @@
* The action row hosts: * The action row hosts:
* • "Edit profile" link — only on the owner's own profile (via the * • "Edit profile" link — only on the owner's own profile (via the
* `editHref` prop set by /u/me layout). * `editHref` prop set by /u/me layout).
* • Reading-settings gear — for every signed-in viewer. It links to * • Reading-settings gear — also owner-only. We use `editHref` as the
* /u/me/settings (a focused page that mounts ReadingSettings in * "is this my profile?" signal (the /u/me layout passes it; the
* inline mode) rather than opening a popover here. A popover * /u/[handle] layout does not), so the two buttons stay in lockstep
* triggered from this centred row tends to spill off the left edge * and visitors on someone else's profile see a clean row with no
* on narrow viewports, so the page-navigation pattern matches Edit * personal-settings affordances. The gear links to /u/me/settings
* Profile and dodges the problem entirely. * (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 * Auth users on someone else's profile reach reading settings via the
* popover gear instead. * sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile
* gear.
*/ */
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuth } from "@/components/auth/auth-provider";
export function ProfileHeader({ export function ProfileHeader({
avatarURL, avatarURL,
@@ -38,16 +40,16 @@ export function ProfileHeader({
handle: string; handle: string;
postCount?: number; postCount?: number;
joinedAt?: string; 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; editHref?: string;
}) { }) {
const { user, loading } = useAuth(); // Both action-row affordances are owner-only and ride on the same
// ReadingSettings appears for every authenticated viewer (own profile // `editHref` signal — when it's absent we render no action row at
// OR someone else's), so an auth user reading a friend's profile can // all, which keeps visitor profile views clean.
// still reach theme/palette/font controls. Anonymous viewers fall back const showReadingSettings = !!editHref;
// to SiteHeader's mobile gear. const showActions = !!editHref;
const showReadingSettings = !loading && !!user;
const showActions = !!editHref || showReadingSettings;
return ( return (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8"> <header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
+11 -1
View File
@@ -22,7 +22,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink, Repeat2 } from "lucide-react";
import type { Post } from "@/lib/social"; import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference"; import { displaySourceLabel } from "@/lib/source-reference";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight"; import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
@@ -46,6 +46,16 @@ export function PostCard({
return ( return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5"> <article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
{/* 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 ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
<Repeat2 className="size-3.5" />
<span>Reshared</span>
</div>
) : null}
{/* Author header — identity on the left, overflow menu on the {/* Author header — identity on the left, overflow menu on the
right. The menu trigger only renders when there are items to right. The menu trigger only renders when there are items to
show (delete for owners, block/report for everyone else), so show (delete for owners, block/report for everyone else), so
+11 -1
View File
@@ -29,7 +29,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ImageIcon } from "lucide-react"; import { ImageIcon, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social"; import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference"; import { displaySourceLabel } from "@/lib/source-reference";
@@ -75,6 +75,16 @@ export function PostScrollCard({
id={`post-${post.id}`} id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24" 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 ? (
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground">
<Repeat2 className="size-3.5" />
<span>Reshared</span>
</div>
) : null}
{/* Author header — identity on the left, three-dot overflow menu {/* Author header — identity on the left, three-dot overflow menu
on the right (Delete for author, Block/Report for everyone on the right (Delete for author, Block/Report for everyone
else; PostCardMenu hides itself when there are no items). */} else; PostCardMenu hides itself when there are no items). */}
+37
View File
@@ -67,6 +67,11 @@ export type Post = {
* collection contents). Always false / absent for anonymous viewers. * collection contents). Always false / absent for anonymous viewers.
* Drives the post-card "Reshare ↔ Send" swap. */ * Drives the post-card "Reshare ↔ Send" swap. */
viewer_has_reshared?: boolean; 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 = { export type Bookmark = {
@@ -100,6 +105,15 @@ export type PublicProfile = {
public_collections: Collection[]; public_collections: Collection[];
}; };
export type Feedback = {
id: string;
user_id?: string;
category: string;
message: string;
contact_email?: string;
created_at: string;
};
// ─── Posts ─────────────────────────────────────────────────────────────── // ─── Posts ───────────────────────────────────────────────────────────────
export const 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>("/feedback", {
method: "POST",
body: JSON.stringify({
category: input.category,
message: input.message,
contact_email: input.contact_email || undefined,
}),
});
},
};
// ─── Public profile ────────────────────────────────────────────────────── // ─── Public profile ──────────────────────────────────────────────────────
export const profile = { export const profile = {