diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index 948094e2..4ad966ea 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -137,14 +137,16 @@ function NotificationRow({ ); - // A like links to your own post; everything else links to the actor's - // profile (when we know their handle). + // A like links to your own post; a mention links to the actor's post you + // were tagged in; everything else links to the actor's profile. const href = n.kind === "post_like" && n.post_id ? `/u/${selfHandle}/posts#post-${n.post_id}` - : n.actor_handle - ? `/u/${n.actor_handle}` - : null; + : n.kind === "post_mention" && n.post_id && n.actor_handle + ? `/u/${n.actor_handle}/posts#post-${n.post_id}` + : n.actor_handle + ? `/u/${n.actor_handle}` + : null; return href ? (
  • @@ -177,6 +179,8 @@ function messageFor(n: AppNotification): string { if (others <= 0) return "liked your post."; return `and ${others} ${others === 1 ? "other" : "others"} liked your post.`; } + case "post_mention": + return "mentioned you in a post."; default: return "sent you a notification."; } diff --git a/app/(app)/u/[handle]/mentions/page.tsx b/app/(app)/u/[handle]/mentions/page.tsx index 2f8d4c23..8b4efd6c 100644 --- a/app/(app)/u/[handle]/mentions/page.tsx +++ b/app/(app)/u/[handle]/mentions/page.tsx @@ -3,23 +3,46 @@ /** * /u/[handle]/mentions — posts that tag this user. * - * Mirror of /u/me/mentions: the tab is wired into the public profile - * shell so visitors see an empty state instead of a 404. Will swap to - * a real feed once the Mentions feature ships. + * Grid of tagged posts (same renderer as the Posts tab). Because a tagged + * post can be authored by anyone, each thumbnail links to its own author's + * scroll feed, anchored to the post. */ -import { AtSign } from "lucide-react"; +import { useEffect, useState } from "react"; +import { profile, type Post } from "@/lib/social"; +import { PostViews } from "@/components/social/post-views"; import { usePublicProfile } from "../profile-context"; export default function PublicProfileMentionsPage() { const data = usePublicProfile(); + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + profile + .mentionsFeed(data.handle, 30) + .then((rows) => { + if (!cancelled) setFeed(rows ?? []); + }) + .catch(() => { + /* show empty rather than block the tab */ + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [data.handle]); + + if (loading) { + return

    Loading…

    ; + } + return ( -
    - -

    - Posts that tag @{data.handle}{" "} - will appear here. Coming soon. -

    -
    + `/u/${p.author_handle ?? data.handle}/posts#post-${p.id}`} + emptyMessage={`Posts that tag @${data.handle} will appear here.`} + /> ); } diff --git a/app/(app)/u/me/mentions/page.tsx b/app/(app)/u/me/mentions/page.tsx index d791e039..4a82082a 100644 --- a/app/(app)/u/me/mentions/page.tsx +++ b/app/(app)/u/me/mentions/page.tsx @@ -1,21 +1,49 @@ "use client"; /** - * /u/me/mentions — placeholder until the Mentions feature ships. + * /u/me/mentions — posts that tag the signed-in user. * - * The tab itself is wired into the profile shell already so users see - * an empty state instead of a 404 when they tap it. + * Grid of tagged posts (same renderer as the Posts tab). Tagged posts can be + * by anyone, so each thumbnail links to its author's scroll feed. */ -import { AtSign } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/components/auth/auth-provider"; +import { profile, type Post } from "@/lib/social"; +import { PostViews } from "@/components/social/post-views"; export default function MentionsPage() { + const { user } = useAuth(); + const handle = user?.handle ?? null; + const [feed, setFeed] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!handle) return; + let cancelled = false; + profile + .mentionsFeed(handle, 30) + .then((rows) => { + if (!cancelled) setFeed(rows ?? []); + }) + .catch(() => { + /* show empty rather than block the tab */ + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [handle]); + + if (loading) { + return

    Loading…

    ; + } + return ( -
    - -

    - Mentions of you across posts will appear here. Coming soon. -

    -
    + `/u/${p.author_handle ?? handle}/posts#post-${p.id}`} + emptyMessage="Posts that tag you will appear here." + /> ); } diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 475e45b1..5bf63829 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -61,6 +61,8 @@ function messageFor(e: IncomingEvent): string { return `${who} accepted your follow request`; case "post_like": return `${who} liked your post`; + case "post_mention": + return `${who} mentioned you in a post`; default: return "You have a new notification"; } diff --git a/components/selection-toolbar/compose-post.tsx b/components/selection-toolbar/compose-post.tsx index 27abe988..318107a8 100644 --- a/components/selection-toolbar/compose-post.tsx +++ b/components/selection-toolbar/compose-post.tsx @@ -24,7 +24,8 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; -import { posts, SocialError } from "@/lib/social"; +import { posts, SocialError, type UserSummary } from "@/lib/social"; +import { MentionAutocomplete } from "@/components/social/mention-autocomplete"; import { buildHighlightUrl } from "@/lib/text-highlight"; import { getSourceReference } from "@/lib/source-reference"; import { compressImage } from "@/lib/compress-image"; @@ -79,6 +80,8 @@ export function ComposePost({ ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Users tagged on this post (separate from the locked caption). + const [mentions, setMentions] = useState([]); async function onFiles(e: React.ChangeEvent) { const files = Array.from(e.target.files ?? []); @@ -149,6 +152,9 @@ export function ComposePost({ image_urls: images .filter((img) => !img.uploading && img.url) .map((img) => img.url), + mention_user_ids: mentions + .map((m) => m.id) + .filter((id): id is string => !!id), }); onPosted?.(); onClose(); @@ -249,6 +255,46 @@ export function ComposePost({ ) : null} +
    +

    + Mention people +

    + {mentions.length > 0 ? ( +
    + {mentions.map((u) => ( + + @{u.handle} + + + ))} +
    + ) : null} +
    + m.id) + .filter((id): id is string => !!id)} + onSelect={(u) => + setMentions((prev) => + prev.some((m) => m.id === u.id) ? prev : [...prev, u], + ) + } + /> +
    +
    + {error ?

    {error}

    : null} + )) + )} + {loading ? ( +
    + +
    + ) : null} + + ) : null} + + ); +} + +function RowAvatar({ src, name }: { src: string | null; name: string }) { + if (src) { + return ( + {name} + ); + } + const initials = + name + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?"; + return ( + + {initials} + + ); +} diff --git a/components/social/mentions-drawer.tsx b/components/social/mentions-drawer.tsx new file mode 100644 index 00000000..3250797f --- /dev/null +++ b/components/social/mentions-drawer.tsx @@ -0,0 +1,209 @@ +"use client"; + +/** + * MentionsDrawer — the users tagged on a post. Right-side drawer on desktop, + * bottom sheet on mobile. Read-only for everyone except the post's author, + * who gets an autocomplete to add people and an X to remove them. + * + * `onCountChange(delta)` lets the card update its mentions-icon badge as the + * author adds/removes, without a refetch. + */ + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { X } from "lucide-react"; +import { toast } from "sonner"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import { posts, type UserSummary } from "@/lib/social"; +import { useAuth } from "@/components/auth/auth-provider"; +import { MentionAutocomplete } from "@/components/social/mention-autocomplete"; +import { cn } from "@/lib/utils"; + +export function MentionsDrawer({ + open, + postId, + authorId, + onClose, + onCountChange, +}: { + open: boolean; + postId: string; + authorId: string; + onClose: () => void; + onCountChange?: (delta: number) => void; +}) { + const isMobile = useMediaQuery("(max-width: 767px)"); + const direction = isMobile ? "bottom" : "right"; + const { user } = useAuth(); + const isAuthor = !!user && user.id === authorId; + + const [list, setList] = useState([]); + const [status, setStatus] = useState<"loading" | "idle" | "error">("loading"); + const reqRef = useRef(0); + + useEffect(() => { + if (!open) return; + // Deferred to a timer so the setState calls aren't synchronous in the + // effect body. + const t = window.setTimeout(() => { + const token = ++reqRef.current; + setStatus("loading"); + posts + .mentions(postId) + .then((res) => { + if (reqRef.current === token) { + setList(res); + setStatus("idle"); + } + }) + .catch(() => { + if (reqRef.current === token) setStatus("error"); + }); + }, 0); + return () => window.clearTimeout(t); + }, [open, postId]); + + async function add(u: UserSummary) { + if (!u.id || list.some((m) => m.id === u.id)) return; + setList((prev) => [...prev, u]); // optimistic + onCountChange?.(1); + try { + await posts.addMention(postId, u.id); + } catch { + setList((prev) => prev.filter((m) => m.id !== u.id)); + onCountChange?.(-1); + toast.error("Couldn't add that person"); + } + } + + async function remove(u: UserSummary) { + if (!u.id) return; + setList((prev) => prev.filter((m) => m.id !== u.id)); // optimistic + onCountChange?.(-1); + try { + await posts.removeMention(postId, u.id); + } catch { + setList((prev) => [...prev, u]); + onCountChange?.(1); + toast.error("Couldn't remove that person"); + } + } + + return ( + { + if (!o) onClose(); + }} + > + + + Mentions + {isAuthor ? ( +
    + m.id).filter((id): id is string => !!id)} + onSelect={add} + placeholder="Add someone…" + /> +
    + ) : null} +
    + +
    + {status === "loading" ? ( + Loading… + ) : status === "error" ? ( + Couldn't load mentions. + ) : list.length === 0 ? ( + + {isAuthor ? "No one tagged yet — add someone above." : "No one is tagged."} + + ) : ( + list.map((u) => ( +
    + + + + + {u.name} + + + @{u.handle} + + + + {isAuthor ? ( + + ) : null} +
    + )) + )} +
    +
    +
    + ); +} + +function RowAvatar({ src, name }: { src: string | null; name: string }) { + if (src) { + return ( + {name} + ); + } + const initials = + name + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?"; + return ( + + {initials} + + ); +} + +function Message({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx index 98c73268..2682fbc3 100644 --- a/components/social/post-scroll-card.tsx +++ b/components/social/post-scroll-card.tsx @@ -29,15 +29,17 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import { ImageIcon, Repeat2 } from "lucide-react"; +import { AtSign, ImageIcon, Repeat2 } from "lucide-react"; import { cn } from "@/lib/utils"; import type { Post, PostImage } from "@/lib/social"; import { displaySourceLabel } from "@/lib/source-reference"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import { useAuth } from "@/components/auth/auth-provider"; import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { PostActionsFooter } from "./post-actions-footer"; import { PostCardMenu } from "./post-card-menu"; import { UpdateBackgroundModal } from "./update-background-modal"; +import { MentionsDrawer } from "./mentions-drawer"; export function PostScrollCard({ post, @@ -52,23 +54,40 @@ export function PostScrollCard({ const hasImages = images.length > 0; const sourceHref = postSourceHref(post); const isMobile = useMediaQuery("(max-width: 767px)"); + const { user } = useAuth(); + const isAuthor = !!user && user.id === post.user_id; const [bgModal, setBgModal] = useState(false); const [expanded, setExpanded] = useState(false); + const [mentionsOpen, setMentionsOpen] = useState(false); + // Live mention count — seeded from the hydrated post, adjusted when the + // author edits tags in the drawer, so the icon appears/disappears in place. + const [mentionCount, setMentionCount] = useState(post.mentions_count ?? 0); - // Update Background stays as a media-overlay button — it's context- - // specific to the visible card surface and reads more naturally - // positioned over the image itself. Delete has moved into the - // header's three-dot menu (PostCardMenu) where Block / Report also - // live, so the overlay is now Update-Background-only. - const ownerCluster = onUpdated ? ( -
    - setBgModal(true)} - icon={} - /> -
    - ) : null; + // The author always gets the mentions entry point (to add/remove); everyone + // else sees it only when the post actually tags someone. + const showMentions = mentionCount > 0 || isAuthor; + + // Top-right media overlay: a Mentions icon and the owner-only + // Update-background button. Delete lives in the header menu. + const topRight = + showMentions || onUpdated ? ( +
    + {showMentions ? ( + setMentionsOpen(true)} + icon={} + /> + ) : null} + {onUpdated ? ( + setBgModal(true)} + icon={} + /> + ) : null} +
    + ) : null; return (
    )} - {ownerCluster} + {topRight} {/* Caption + citation — reference sits directly under the media @@ -162,6 +181,14 @@ export function PostScrollCard({ onUpdated={onUpdated} /> ) : null} + + setMentionsOpen(false)} + onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))} + />
    ); } diff --git a/components/text-selection-toolbar.tsx b/components/text-selection-toolbar.tsx index 6849285d..31785c62 100644 --- a/components/text-selection-toolbar.tsx +++ b/components/text-selection-toolbar.tsx @@ -488,17 +488,6 @@ export function TextSelectionToolbar() { icon: , onClick: onSaveToCollection, }, - { - key: "mention", - label: "Mention", - description: "Tag someone with this quote", - icon: , - onClick: () => { - setShowMore(false); - if (isMobile) restoreSelection(); - else interactingRef.current = false; - }, - }, ]; const publicMoreItems: MoreItem[] = [ @@ -532,18 +521,6 @@ export function TextSelectionToolbar() { await onBookmarkOrCollect(); }, }, - { - key: "mention-public", - label: "Mention", - description: "Tag someone with this quote", - icon: , - locked: true, - onClick: async () => { - setShowMore(false); - if (!(await requireAuth())) return; - // TODO: Implement Mention - }, - }, ]; const moreItems = isAuthed ? authedMoreItems : publicMoreItems; diff --git a/lib/social.ts b/lib/social.ts index 35687fba..91911e04 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -83,6 +83,8 @@ export type Post = { /** Whether the calling viewer has liked this post. Always false / absent * for anonymous viewers. */ viewer_has_liked?: boolean; + /** How many users are tagged on this post (drives the mentions icon). */ + mentions_count?: number; }; /** Result of a like / unlike toggle: the new viewer state + effective total. */ @@ -169,6 +171,7 @@ export const posts = { source_url: string; source_title?: string; image_urls?: string[]; + mention_user_ids?: string[]; }) { return call("/posts", { method: "POST", @@ -177,9 +180,35 @@ export const posts = { source_url: input.source_url, source_title: input.source_title ?? "", image_urls: input.image_urls ?? [], + mention_user_ids: input.mention_user_ids ?? [], }), }); }, + /** Prioritised mention suggestions (following → followers → others). Returns + * one page; the popover loads more by `offset` until a short page. */ + mentionSuggest(q: string, opts: { limit?: number; offset?: number } = {}) { + const params = new URLSearchParams({ + limit: String(opts.limit ?? 20), + offset: String(opts.offset ?? 0), + }); + if (q.trim()) params.set("q", q.trim()); + return call(`/posts/mention-suggest?${params}`); + }, + /** Users tagged on a post (insertion order). Public. */ + mentions(id: string) { + return call(`/posts/${id}/mentions`); + }, + /** Tag a user on the post (author only, idempotent). */ + addMention(id: string, userId: string) { + return call(`/posts/${id}/mentions`, { + method: "POST", + body: JSON.stringify({ user_id: userId }), + }); + }, + /** Untag a user (author only). */ + removeMention(id: string, userId: string) { + return call(`/posts/${id}/mentions/${userId}`, { method: "DELETE" }); + }, get(id: string) { return call(`/posts/${id}`); }, @@ -412,6 +441,14 @@ export const profile = { }); return call(`/u/${encodeURIComponent(handle)}/feed?${qs}`); }, + /** Posts that tag this user (the Mentions tab), newest first. */ + mentionsFeed(handle: string, limit = 30, offset = 0) { + const qs = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }); + return call(`/u/${encodeURIComponent(handle)}/mentions?${qs}`); + }, }; // ─── Relationships (follow / block / report) ─────────────────────────────── @@ -553,6 +590,9 @@ export const relationships = { /** Lightweight user row in a followers / following list. */ export type UserSummary = { + /** Present where the caller needs it (mentions); absent in follower/like + * lists that link by handle. */ + id?: string; handle: string; name: string; avatar_url: string | null;