feat: profile bio
This commit is contained in:
@@ -130,6 +130,7 @@ export default function PublicProfileLayout(props: {
|
|||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
isDirectDisciple={!!data.is_direct_disciple}
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
|
bio={data.bio}
|
||||||
/>
|
/>
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -176,6 +177,7 @@ export default function PublicProfileLayout(props: {
|
|||||||
joinedAt={data.joined_at}
|
joinedAt={data.joined_at}
|
||||||
country={data.country}
|
country={data.country}
|
||||||
isDirectDisciple={!!data.is_direct_disciple}
|
isDirectDisciple={!!data.is_direct_disciple}
|
||||||
|
bio={data.bio}
|
||||||
/>
|
/>
|
||||||
<ProfileTabs tabs={tabs} />
|
<ProfileTabs tabs={tabs} />
|
||||||
<PublicProfileContext.Provider value={data}>
|
<PublicProfileContext.Provider value={data}>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
|||||||
handle={user.handle}
|
handle={user.handle}
|
||||||
joinedAt={user.created_at}
|
joinedAt={user.created_at}
|
||||||
isDirectDisciple={!!user.is_direct_disciple}
|
isDirectDisciple={!!user.is_direct_disciple}
|
||||||
|
bio={user.bio}
|
||||||
editHref="/settings/profile"
|
editHref="/settings/profile"
|
||||||
postCount={counts?.posts}
|
postCount={counts?.posts}
|
||||||
followerCount={counts?.followers}
|
followerCount={counts?.followers}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { compressImage } from "@/lib/compress-image";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ export function ProfileEditForm({
|
|||||||
const [name, setName] = useState(user.name);
|
const [name, setName] = useState(user.name);
|
||||||
const [handle, setHandle] = useState(user.handle);
|
const [handle, setHandle] = useState(user.handle);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
||||||
|
const [bio, setBio] = useState<string>(user.bio ?? "");
|
||||||
const [country, setCountry] = useState<string>(user.country ?? "");
|
const [country, setCountry] = useState<string>(user.country ?? "");
|
||||||
// Devotional details. Each "" means "not specified".
|
// Devotional details. Each "" means "not specified".
|
||||||
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
|
const [sampradaya, setSampradaya] = useState<string>(user.sampradaya ?? "");
|
||||||
@@ -92,11 +98,15 @@ export function ProfileEditForm({
|
|||||||
const origRounds =
|
const origRounds =
|
||||||
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
|
user.daily_chanting_rounds != null ? String(user.daily_chanting_rounds) : "";
|
||||||
const effInitiatedAt = initiated ? initiatedAt : "";
|
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 =
|
const dirty =
|
||||||
name.trim() !== user.name ||
|
name.trim() !== user.name ||
|
||||||
handle.trim() !== user.handle ||
|
handle.trim() !== user.handle ||
|
||||||
avatarUrl !== user.avatar_url ||
|
avatarUrl !== user.avatar_url ||
|
||||||
|
bio.trim() !== (user.bio ?? "") ||
|
||||||
country !== (user.country ?? "") ||
|
country !== (user.country ?? "") ||
|
||||||
sampradaya !== (user.sampradaya ?? "") ||
|
sampradaya !== (user.sampradaya ?? "") ||
|
||||||
gender !== (user.gender ?? "") ||
|
gender !== (user.gender ?? "") ||
|
||||||
@@ -108,6 +118,13 @@ export function ProfileEditForm({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
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).
|
// Client-side guard for the round count (the BFF re-checks 1–1008).
|
||||||
if (rounds !== "") {
|
if (rounds !== "") {
|
||||||
const n = Number(rounds);
|
const n = Number(rounds);
|
||||||
@@ -126,6 +143,8 @@ export function ProfileEditForm({
|
|||||||
if (name.trim() !== user.name) patch.name = name.trim();
|
if (name.trim() !== user.name) patch.name = name.trim();
|
||||||
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
|
if (avatarUrl !== user.avatar_url) patch.avatar_url = avatarUrl ?? "";
|
||||||
if (handle.trim() !== user.handle) patch.handle = handle.trim();
|
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;
|
if (country !== (user.country ?? "")) patch.country = country;
|
||||||
// "" clears the lineage; a code sets it.
|
// "" clears the lineage; a code sets it.
|
||||||
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
|
if (sampradaya !== (user.sampradaya ?? "")) patch.sampradaya = sampradaya;
|
||||||
@@ -221,6 +240,40 @@ export function ProfileEditForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="profile-bio">Bio</Label>
|
||||||
|
{/* 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. */}
|
||||||
|
<MentionTextarea
|
||||||
|
id="profile-bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={setBio}
|
||||||
|
rows={3}
|
||||||
|
maxLength={BIO_MAX}
|
||||||
|
placeholder="A little about you… mention devotees with @handle"
|
||||||
|
// Mirrors the <Input> 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"
|
||||||
|
/>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type <span className="font-medium text-foreground">@</span> to
|
||||||
|
mention another devotee — mentions become links on your profile.
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs tabular-nums",
|
||||||
|
bioCount > BIO_MAX
|
||||||
|
? "font-medium text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bioCount}/{BIO_MAX}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="profile-handle">Handle</Label>
|
<Label htmlFor="profile-handle">Handle</Label>
|
||||||
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
{/* "@" sits inside the field as a fixed, non-editable prefix —
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
||||||
import { ProfileActions } from "@/components/profile/profile-actions";
|
import { ProfileActions } from "@/components/profile/profile-actions";
|
||||||
import { DiscipleBadge } from "@/components/profile/disciple-badge";
|
import { DiscipleBadge } from "@/components/profile/disciple-badge";
|
||||||
|
import { MentionText } from "@/components/social/mention-text";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import {
|
import {
|
||||||
FollowListDrawer,
|
FollowListDrawer,
|
||||||
@@ -55,6 +56,7 @@ export function ProfileHeader({
|
|||||||
country,
|
country,
|
||||||
interactiveCounts = true,
|
interactiveCounts = true,
|
||||||
isDirectDisciple = false,
|
isDirectDisciple = false,
|
||||||
|
bio,
|
||||||
}: {
|
}: {
|
||||||
avatarURL: string | null;
|
avatarURL: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -87,6 +89,10 @@ export function ProfileHeader({
|
|||||||
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
|
* one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait
|
||||||
* on the avatar; tapping it explains the mark. */
|
* on the avatar; tapping it explains the mark. */
|
||||||
isDirectDisciple?: boolean;
|
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
|
// Owner affordances ride on `editHref`; the follow-action row rides on
|
||||||
// `enableActions`. Mutually exclusive by construction.
|
// `enableActions`. Mutually exclusive by construction.
|
||||||
@@ -203,6 +209,17 @@ export function ProfileHeader({
|
|||||||
onResult={handleResult}
|
onResult={handleResult}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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() ? (
|
||||||
|
<MentionText
|
||||||
|
text={bio.trim()}
|
||||||
|
className="self-stretch text-left text-sm sm:text-[15px] leading-relaxed text-foreground"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
{avatarURL ? (
|
{avatarURL ? (
|
||||||
<AvatarZoomModal
|
<AvatarZoomModal
|
||||||
|
|||||||
@@ -18,29 +18,10 @@ import { toast } from "sonner";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
|
import { comments as commentsApi, SocialError, type Comment } from "@/lib/social";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
|
import { renderMentions } from "@/components/social/mention-text";
|
||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import { CommentMenu } from "./comment-menu";
|
import { CommentMenu } from "./comment-menu";
|
||||||
|
|
||||||
// Split a comment body into text + clickable @handle links. The capturing
|
|
||||||
// group keeps the delimiters so they can be turned into profile links.
|
|
||||||
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
|
|
||||||
function renderBody(text: string) {
|
|
||||||
return text.split(MENTION_TOKEN).map((part, i) => {
|
|
||||||
if (part.length > 1 && part[0] === "@") {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={i}
|
|
||||||
href={`/u/${part.slice(1)}`}
|
|
||||||
className="font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommentRow({
|
export function CommentRow({
|
||||||
comment,
|
comment,
|
||||||
viewerId,
|
viewerId,
|
||||||
@@ -172,7 +153,7 @@ export function CommentRow({
|
|||||||
@{comment.reply_to_handle}{" "}
|
@{comment.reply_to_handle}{" "}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{renderBody(comment.body)}
|
{renderMentions(comment.body)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Inline action row */}
|
{/* Inline action row */}
|
||||||
|
|||||||
@@ -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 <br> 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 (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
href={`/u/${part.slice(1)}`}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionText({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className={cn("whitespace-pre-line", className)}>
|
||||||
|
{renderMentions(text)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,22 +33,30 @@ const SUGGEST_LIMIT = 8;
|
|||||||
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
|
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
|
||||||
|
|
||||||
type Mention = { start: number; query: string };
|
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<
|
export const MentionTextarea = forwardRef<
|
||||||
HTMLTextAreaElement,
|
HTMLTextAreaElement,
|
||||||
{
|
{
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
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;
|
onSubmit?: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
/** Forwarded to the <textarea> so a <Label htmlFor> can address it. */
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
>(function MentionTextarea(
|
>(function MentionTextarea(
|
||||||
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 },
|
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1, id },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const innerRef = useRef<HTMLTextAreaElement>(null);
|
const innerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -73,10 +81,18 @@ export const MentionTextarea = forwardRef<
|
|||||||
setMention({ start: caret - m[2].length - 1, query: m[2] });
|
setMention({ start: caret - m[2].length - 1, query: m[2] });
|
||||||
setActive(0);
|
setActive(0);
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
// Prefer opening ABOVE the field: that's what the comment composer needs
|
||||||
|
// (it sits at the bottom of a drawer, often above the on-screen keyboard).
|
||||||
|
// But flip BELOW when the field is too near the top of the viewport for the
|
||||||
|
// panel to fit — otherwise the list renders off-screen and the feature
|
||||||
|
// looks broken (e.g. the bio field high up on the profile editor).
|
||||||
|
const fitsAbove = r.top >= PANEL_MAX_H + 8;
|
||||||
setPos({
|
setPos({
|
||||||
left: r.left,
|
left: r.left,
|
||||||
bottom: window.innerHeight - r.top + 6,
|
|
||||||
width: r.width,
|
width: r.width,
|
||||||
|
...(fitsAbove
|
||||||
|
? { bottom: window.innerHeight - r.top + 6 }
|
||||||
|
: { top: r.bottom + 6 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +159,12 @@ export const MentionTextarea = forwardRef<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
// Only hijack Enter when the caller actually wants submit-on-Enter.
|
||||||
|
// Without the `onSubmit` guard we'd swallow the keystroke for multi-line
|
||||||
|
// callers (e.g. the profile bio), leaving them unable to type a newline.
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && onSubmit) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit?.();
|
onSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +172,7 @@ export const MentionTextarea = forwardRef<
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<textarea
|
<textarea
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
@@ -180,8 +200,10 @@ export const MentionTextarea = forwardRef<
|
|||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: pos.left,
|
left: pos.left,
|
||||||
bottom: pos.bottom,
|
|
||||||
width: pos.width,
|
width: pos.width,
|
||||||
|
...(pos.top !== undefined
|
||||||
|
? { top: pos.top }
|
||||||
|
: { bottom: pos.bottom }),
|
||||||
}}
|
}}
|
||||||
// pointer-events-auto: the comments drawer is a modal vaul sheet
|
// pointer-events-auto: the comments drawer is a modal vaul sheet
|
||||||
// (body gets pointer-events:none); without this the list would be
|
// (body gets pointer-events:none); without this the list would be
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export type User = {
|
|||||||
/** True when an admin has linked this account to the disciples directory —
|
/** True when an admin has linked this account to the disciples directory —
|
||||||
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
|
* one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */
|
||||||
is_direct_disciple?: boolean;
|
is_direct_disciple?: boolean;
|
||||||
|
/** Short self-introduction (≤200 chars), plain text with `@handle`
|
||||||
|
* mentions linkified at render time. null when unset. */
|
||||||
|
bio: string | null;
|
||||||
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
/** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null
|
||||||
* when unknown. Captured best-effort at signup; user-editable. */
|
* when unknown. Captured best-effort at signup; user-editable. */
|
||||||
country: string | null;
|
country: string | null;
|
||||||
@@ -225,6 +228,8 @@ export const auth = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
handle?: string;
|
handle?: string;
|
||||||
|
/** Plain text, max 200 chars; "" clears it. */
|
||||||
|
bio?: string;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
sampradaya?: string;
|
sampradaya?: string;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ export type PublicProfile = {
|
|||||||
handle: string;
|
handle: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
/** Short self-introduction (≤200 chars), plain text with `@handle`
|
||||||
|
* mentions linkified at render time. null when unset. */
|
||||||
|
bio: string | null;
|
||||||
joined_at: string;
|
joined_at: string;
|
||||||
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
|
/** ISO-3166-1 alpha-2 account country, or null when unknown. */
|
||||||
country: string | null;
|
country: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user