@@ -176,6 +177,7 @@ export default function PublicProfileLayout(props: {
joinedAt={data.joined_at}
country={data.country}
isDirectDisciple={!!data.is_direct_disciple}
+ bio={data.bio}
/>
diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx
index 701f532d..b8a1a6e4 100644
--- a/app/(app)/u/me/layout.tsx
+++ b/app/(app)/u/me/layout.tsx
@@ -141,6 +141,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
handle={user.handle}
joinedAt={user.created_at}
isDirectDisciple={!!user.is_direct_disciple}
+ bio={user.bio}
editHref="/settings/profile"
postCount={counts?.posts}
followerCount={counts?.followers}
diff --git a/components/profile/profile-edit-form.tsx b/components/profile/profile-edit-form.tsx
index 540eeecb..134062ab 100644
--- a/components/profile/profile-edit-form.tsx
+++ b/components/profile/profile-edit-form.tsx
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { MentionTextarea } from "@/components/social/mention-textarea";
+import { cn } from "@/lib/utils";
+
+/** Profile bio cap. Mirrors the BFF's service.MaxBioRunes. */
+const BIO_MAX = 200;
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { DatePicker } from "@/components/ui/date-picker";
@@ -70,6 +75,7 @@ export function ProfileEditForm({
const [name, setName] = useState(user.name);
const [handle, setHandle] = useState(user.handle);
const [avatarUrl, setAvatarUrl] = useState(user.avatar_url);
+ const [bio, setBio] = useState(user.bio ?? "");
const [country, setCountry] = useState(user.country ?? "");
// Devotional details. Each "" means "not specified".
const [sampradaya, setSampradaya] = useState(user.sampradaya ?? "");
@@ -92,11 +98,15 @@ export function ProfileEditForm({
const origRounds =
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
const effInitiatedAt = initiated ? initiatedAt : "";
+ // Spread, not .length — counts code points so emoji and Devanāgarī match
+ // the server's rune-based cap instead of over-counting UTF-16 units.
+ const bioCount = [...bio.trim()].length;
const dirty =
name.trim() !== user.name ||
handle.trim() !== user.handle ||
avatarUrl !== user.avatar_url ||
+ bio.trim() !== (user.bio ?? "") ||
country !== (user.country ?? "") ||
sampradaya !== (user.sampradaya ?? "") ||
gender !== (user.gender ?? "") ||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
e.preventDefault();
setError(null);
+ // Counted in code points to match the BFF's rune-based cap, so a bio in
+ // Devanāgarī is measured the same way as one in Latin script.
+ if ([...bio.trim()].length > BIO_MAX) {
+ setError(`Bio must be ${BIO_MAX} characters or fewer.`);
+ return;
+ }
+
// Client-side guard for the round count (the BFF re-checks 1–1008).
if (rounds !== "") {
const n = Number(rounds);
@@ -126,6 +143,8 @@ export function ProfileEditForm({
if (name.trim() !== user.name) patch.name = name.trim();
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
if (handle.trim() !== user.handle) patch.handle = handle.trim();
+ // "" clears the bio.
+ if (bio.trim() !== (user.bio ?? "")) patch.bio = bio.trim();
if (country !== (user.country ?? "")) patch.country = country;
// "" clears the lineage; a code sets it.
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
@@ -221,6 +240,40 @@ export function ProfileEditForm({
/>
+
+
+ {/* MentionTextarea (shared with the post composer and comment box)
+ gives us the debounced "@" suggestion list for free — typing an
+ @token opens a people picker prioritised following → followers. */}
+ primitive's chrome so the bio sits flush
+ // with the other fields (auto height instead of the fixed h-10).
+ className="w-full min-w-0 resize-y rounded-lg border border-input bg-card px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-card"
+ />
+
+
+ Type @ to
+ mention another devotee — mentions become links on your profile.
+
{/* "@" sits inside the field as a fixed, non-editable prefix —
diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx
index d935a457..d55d8568 100644
--- a/components/profile/profile-header.tsx
+++ b/components/profile/profile-header.tsx
@@ -35,6 +35,7 @@ import { cn } from "@/lib/utils";
import type { RelationshipResult, RelationshipState } from "@/lib/social";
import { ProfileActions } from "@/components/profile/profile-actions";
import { DiscipleBadge } from "@/components/profile/disciple-badge";
+import { MentionText } from "@/components/social/mention-text";
import { UserAvatar } from "@/components/ui/user-avatar";
import {
FollowListDrawer,
@@ -55,6 +56,7 @@ export function ProfileHeader({
country,
interactiveCounts = true,
isDirectDisciple = false,
+ bio,
}: {
avatarURL: string | null;
name: string;
@@ -87,6 +89,10 @@ export function ProfileHeader({
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
* on the avatar; tapping it explains the mark. */
isDirectDisciple?: boolean;
+ /** The profile's bio. Plain text — `@handle` mentions are linkified on
+ * render. Rendered left-aligned across the full column below the centred
+ * identity block; omitted entirely when empty. */
+ bio?: string | null;
}) {
// Owner affordances ride on `editHref`; the follow-action row rides on
// `enableActions`. Mutually exclusive by construction.
@@ -203,6 +209,17 @@ export function ProfileHeader({
onResult={handleResult}
/>
) : null}
+
+ {/* Bio — left-aligned across the full column, unlike the centred
+ identity block above it. Prose reads badly centred once it wraps
+ past one line, and `self-stretch` overrides the header's
+ `items-center` so it spans edge to edge. */}
+ {bio?.trim() ? (
+
+ ) : null}
{avatarURL ? (
{
- if (part.length > 1 && part[0] === "@") {
- return (
-
- {part}
-
- );
- }
- return part;
- });
-}
-
export function CommentRow({
comment,
viewerId,
@@ -172,7 +153,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "}
) : null}
- {renderBody(comment.body)}
+ {renderMentions(comment.body)}
{/* Inline action row */}
diff --git a/components/social/mention-text.tsx b/components/social/mention-text.tsx
new file mode 100644
index 00000000..0419a661
--- /dev/null
+++ b/components/social/mention-text.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+/**
+ * MentionText — renders user-authored plain text with `@handle` tokens turned
+ * into profile links.
+ *
+ * The single place that decides what a mention looks like. Shared by comment
+ * bodies and profile bios so the two can't drift apart, and so the
+ * "@handle" grammar lives next to the regex the composer
+ * (`MentionTextarea`) uses to detect it.
+ *
+ * Input is always PLAIN TEXT, never HTML — we split on the token and emit
+ * React nodes, so nothing user-supplied is ever interpreted as markup.
+ * `whitespace-pre-line` on the wrapper preserves the author's line breaks
+ * (bios are multi-line) without needing injection.
+ */
+
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+
+/** Capturing group keeps the delimiters, so split() yields text and tokens
+ * alternately. Handle charset matches MentionTextarea's MENTION_RE. */
+const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
+
+/** Split text into plain runs + clickable @handle links. Exported for callers
+ * that need the nodes without the wrapper element. */
+export function renderMentions(text: string) {
+ return text.split(MENTION_TOKEN).map((part, i) => {
+ if (part.length > 1 && part[0] === "@") {
+ return (
+
+ {part}
+
+ );
+ }
+ return part;
+ });
+}
+
+export function MentionText({
+ text,
+ className,
+}: {
+ text: string;
+ className?: string;
+}) {
+ return (
+
+ {renderMentions(text)}
+
+ );
+}
diff --git a/components/social/mention-textarea.tsx b/components/social/mention-textarea.tsx
index 8d262e9f..da16145e 100644
--- a/components/social/mention-textarea.tsx
+++ b/components/social/mention-textarea.tsx
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string };
-type PanelPos = { left: number; bottom: number; width: number };
+/** Exactly one of `top`/`bottom` is set — see the flip logic in detect(). */
+type PanelPos = { left: number; width: number; top?: number; bottom?: number };
+
+/** Keep in sync with the panel's `max-h-56` (14rem). Used to decide whether
+ * the suggestion list fits above the field or has to flip below it. */
+const PANEL_MAX_H = 224;
export const MentionTextarea = forwardRef<
HTMLTextAreaElement,
{
value: string;
onChange: (v: string) => void;
- /** Called on Enter (no Shift) when the suggestion list is closed. */
+ /** Called on Enter (no Shift) when the suggestion list is closed. Omit for
+ * multi-line fields — without it, Enter inserts a newline as usual. */
onSubmit?: () => void;
placeholder?: string;
className?: string;
maxLength?: number;
rows?: number;
+ /** Forwarded to the