feat: mention
This commit is contained in:
@@ -137,14 +137,16 @@ function NotificationRow({
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 ? (
|
||||
<li>
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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<Post[]>([]);
|
||||
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 <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
|
||||
<AtSign className="size-8" />
|
||||
<p className="text-sm">
|
||||
Posts that tag <span className="text-foreground">@{data.handle}</span>{" "}
|
||||
will appear here. Coming soon.
|
||||
</p>
|
||||
</div>
|
||||
<PostViews
|
||||
posts={feed}
|
||||
hrefFor={(p) => `/u/${p.author_handle ?? data.handle}/posts#post-${p.id}`}
|
||||
emptyMessage={`Posts that tag @${data.handle} will appear here.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Post[]>([]);
|
||||
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 <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center gap-3 py-12 text-muted-foreground">
|
||||
<AtSign className="size-8" />
|
||||
<p className="text-sm">
|
||||
Mentions of you across posts will appear here. Coming soon.
|
||||
</p>
|
||||
</div>
|
||||
<PostViews
|
||||
posts={feed}
|
||||
hrefFor={(p) => `/u/${p.author_handle ?? handle}/posts#post-${p.id}`}
|
||||
emptyMessage="Posts that tag you will appear here."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
// Users tagged on this post (separate from the locked caption).
|
||||
const [mentions, setMentions] = useState<UserSummary[]>([]);
|
||||
|
||||
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
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}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Mention people
|
||||
</p>
|
||||
{mentions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{mentions.map((u) => (
|
||||
<span
|
||||
key={u.id ?? u.handle}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-sm text-foreground"
|
||||
>
|
||||
@{u.handle}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMentions((prev) => prev.filter((m) => m.id !== u.id))
|
||||
}
|
||||
aria-label={`Remove @${u.handle}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2">
|
||||
<MentionAutocomplete
|
||||
excludeIds={mentions
|
||||
.map((m) => m.id)
|
||||
.filter((id): id is string => !!id)}
|
||||
onSelect={(u) =>
|
||||
setMentions((prev) =>
|
||||
prev.some((m) => m.id === u.id) ? prev : [...prev, u],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MentionAutocomplete — a search input with a suggestions popover for tagging
|
||||
* users. Suggestions come from the backend prioritised (following → followers
|
||||
* → others), 20 per page, and the popover infinite-scrolls (≈5 rows visible).
|
||||
* Already-selected users are filtered out. Calls onSelect with the chosen
|
||||
* user; the caller manages the selected set.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { posts, type UserSummary } from "@/lib/social";
|
||||
|
||||
const PAGE = 20;
|
||||
|
||||
export function MentionAutocomplete({
|
||||
excludeIds = [],
|
||||
onSelect,
|
||||
placeholder = "Search people to mention…",
|
||||
autoFocus = false,
|
||||
}: {
|
||||
excludeIds?: string[];
|
||||
onSelect: (user: UserSummary) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UserSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const reqRef = useRef(0);
|
||||
const busyRef = useRef(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// (Re)load page 0 on open / query change (debounced — timer callback, so
|
||||
// setState isn't synchronous-in-effect).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const token = ++reqRef.current;
|
||||
busyRef.current = true;
|
||||
setLoading(true);
|
||||
posts
|
||||
.mentionSuggest(q, { limit: PAGE, offset: 0 })
|
||||
.then((res) => {
|
||||
if (reqRef.current !== token) return;
|
||||
setItems(res);
|
||||
setHasMore(res.length === PAGE);
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqRef.current === token) {
|
||||
setItems([]);
|
||||
setHasMore(false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (reqRef.current === token) {
|
||||
setLoading(false);
|
||||
busyRef.current = false;
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, open]);
|
||||
|
||||
function loadMore() {
|
||||
if (busyRef.current || !hasMore) return;
|
||||
const token = reqRef.current;
|
||||
busyRef.current = true;
|
||||
setLoading(true);
|
||||
posts
|
||||
.mentionSuggest(q, { limit: PAGE, offset: items.length })
|
||||
.then((res) => {
|
||||
if (reqRef.current !== token) return;
|
||||
setItems((prev) => [...prev, ...res]);
|
||||
setHasMore(res.length === PAGE);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (reqRef.current === token) {
|
||||
setLoading(false);
|
||||
busyRef.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close on outside click / Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDown(e: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("pointerdown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const excluded = new Set(excludeIds);
|
||||
const visible = items.filter((u) => !u.id || !excluded.has(u.id));
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={q}
|
||||
autoFocus={autoFocus}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
|
||||
loadMore();
|
||||
}
|
||||
}}
|
||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto overscroll-contain rounded-lg border border-border bg-card shadow-lg"
|
||||
>
|
||||
{visible.length === 0 && !loading ? (
|
||||
<p className="px-3 py-3 text-sm text-muted-foreground">
|
||||
{q.trim() ? "No people found." : "No suggestions."}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((u) => (
|
||||
<button
|
||||
key={u.id ?? u.handle}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()} // keep input focused
|
||||
onClick={() => {
|
||||
onSelect(u);
|
||||
setQ("");
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<RowAvatar src={u.avatar_url} name={u.name} />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{u.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
@{u.handle}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAvatar({ src, name }: { src: string | null; name: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initials =
|
||||
name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
return (
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary ring-1 ring-foreground/10">
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<UserSummary[]>([]);
|
||||
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 (
|
||||
<Drawer
|
||||
open={open}
|
||||
direction={direction}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
className={cn(
|
||||
"bg-card flex flex-col",
|
||||
isMobile ? "h-[80vh]" : "h-full w-[88vw] max-w-sm",
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="border-b border-border text-left">
|
||||
<DrawerTitle className="text-base">Mentions</DrawerTitle>
|
||||
{isAuthor ? (
|
||||
<div className="mt-2">
|
||||
<MentionAutocomplete
|
||||
excludeIds={list.map((m) => m.id).filter((id): id is string => !!id)}
|
||||
onSelect={add}
|
||||
placeholder="Add someone…"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{status === "loading" ? (
|
||||
<Message>Loading…</Message>
|
||||
) : status === "error" ? (
|
||||
<Message>Couldn't load mentions.</Message>
|
||||
) : list.length === 0 ? (
|
||||
<Message>
|
||||
{isAuthor ? "No one tagged yet — add someone above." : "No one is tagged."}
|
||||
</Message>
|
||||
) : (
|
||||
list.map((u) => (
|
||||
<div
|
||||
key={u.id ?? u.handle}
|
||||
className="flex h-16 items-center gap-3 px-4 hover:bg-muted/40"
|
||||
>
|
||||
<Link
|
||||
href={`/u/${u.handle}`}
|
||||
onClick={onClose}
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<RowAvatar src={u.avatar_url} name={u.name} />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{u.name}
|
||||
</span>
|
||||
<span className="block truncate text-sm text-muted-foreground">
|
||||
@{u.handle}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
{isAuthor ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(u)}
|
||||
aria-label={`Remove @${u.handle}`}
|
||||
className="shrink-0 rounded-full p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAvatar({ src, name }: { src: string | null; name: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initials =
|
||||
name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
return (
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary ring-1 ring-foreground/10">
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<OwnerButton
|
||||
label="Update background"
|
||||
onClick={() => setBgModal(true)}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
|
||||
{showMentions ? (
|
||||
<OwnerButton
|
||||
label="See who's mentioned"
|
||||
onClick={() => setMentionsOpen(true)}
|
||||
icon={<AtSign className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
{onUpdated ? (
|
||||
<OwnerButton
|
||||
label="Update background"
|
||||
onClick={() => setBgModal(true)}
|
||||
icon={<ImageIcon className="size-4" />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -125,7 +144,7 @@ export function PostScrollCard({
|
||||
) : (
|
||||
<QuotePanel post={post} />
|
||||
)}
|
||||
{ownerCluster}
|
||||
{topRight}
|
||||
</div>
|
||||
|
||||
{/* Caption + citation — reference sits directly under the media
|
||||
@@ -162,6 +181,14 @@ export function PostScrollCard({
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MentionsDrawer
|
||||
open={mentionsOpen}
|
||||
postId={post.id}
|
||||
authorId={post.user_id}
|
||||
onClose={() => setMentionsOpen(false)}
|
||||
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,17 +488,6 @@ export function TextSelectionToolbar() {
|
||||
icon: <FolderPlus className="size-5" />,
|
||||
onClick: onSaveToCollection,
|
||||
},
|
||||
{
|
||||
key: "mention",
|
||||
label: "Mention",
|
||||
description: "Tag someone with this quote",
|
||||
icon: <AtSign className="size-5" />,
|
||||
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: <AtSign className="size-5" />,
|
||||
locked: true,
|
||||
onClick: async () => {
|
||||
setShowMore(false);
|
||||
if (!(await requireAuth())) return;
|
||||
// TODO: Implement Mention
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;
|
||||
|
||||
@@ -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<Post>("/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<UserSummary[]>(`/posts/mention-suggest?${params}`);
|
||||
},
|
||||
/** Users tagged on a post (insertion order). Public. */
|
||||
mentions(id: string) {
|
||||
return call<UserSummary[]>(`/posts/${id}/mentions`);
|
||||
},
|
||||
/** Tag a user on the post (author only, idempotent). */
|
||||
addMention(id: string, userId: string) {
|
||||
return call<void>(`/posts/${id}/mentions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
});
|
||||
},
|
||||
/** Untag a user (author only). */
|
||||
removeMention(id: string, userId: string) {
|
||||
return call<void>(`/posts/${id}/mentions/${userId}`, { method: "DELETE" });
|
||||
},
|
||||
get(id: string) {
|
||||
return call<Post>(`/posts/${id}`);
|
||||
},
|
||||
@@ -412,6 +441,14 @@ export const profile = {
|
||||
});
|
||||
return call<Post[]>(`/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<Post[]>(`/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;
|
||||
|
||||
Reference in New Issue
Block a user