/**
* Post — a card carrying something somebody said, and what everyone did about it.
*
* Four shapes, because a feed is not one thing. `feed` is the full card: author,
* body, media and a row of counts. `vote` puts a score pill beside a headline
* and a thumbnail, the way a ranked community reads. `compact` drops the media
* and puts the name and handle on one line, for a dense timeline. `media` gives
* the image the whole card and lays the author over it.
*
* What they share is the anatomy — the same `Post.Header`, `Post.Body`,
* `Post.Footer` in every one — so moving between them is a prop rather than a
* rewrite. The variant decides padding, media shape and where the author sits;
* it does not decide which parts exist.
*
* ```tsx
*
*
*
*
*
* I've been paying off my credit card #FinancialFreedom
*
*
*
*
*
*
*
*
* ```
*
* ## Counts change under the finger
*
* Every control here is a toggle over a number, and the number is the point: a
* like that lights up but leaves `215` sitting there has not told you it
* counted. So a stat that changes animates the old value out and the new one in
* along the direction of the change, and `Post.Votes` does the same with its
* arrows. The alternative — repainting the digits in place — is indisting-
* uishable from a re-render, which is exactly the doubt the animation exists to
* remove.
*/
import {
createContext,
forwardRef,
useContext,
useEffect,
useMemo,
useRef,
type ComponentType,
type ReactNode,
} from 'react';
import {
Image,
Pressable,
View,
type ImageSourcePropType,
type PressableProps,
type ViewProps,
} from 'react-native';
import Animated, {
FadeIn,
FadeInDown,
FadeInUp,
FadeOut,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import {
ArrowDownIcon,
ArrowUpIcon,
BadgeCheckIcon,
type IconProps,
} from '../../icons';
import { usePrevious } from '../../hooks/use-previous';
import { AnimatedPressable } from '../../primitives/animated-pressable';
import { Text, textChildren } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
import { Avatar } from '../avatar';
/** Long enough to see the value move, short enough to keep up with a fast tap. */
const COUNT_DURATION = 160;
/** The pop on a toggle: past 1 and back, so the press has a shape. */
const POP = { mass: 0.4, damping: 12, stiffness: 320 } as const;
const postVariants = tv({
slots: {
root: 'w-full overflow-hidden rounded-2xl border border-border bg-card',
header: 'flex-row items-start gap-3',
author: 'min-w-0 flex-1 gap-0.5',
authorLine: 'flex-row items-center gap-1.5',
name: 'font-semibold text-card-foreground',
handle: 'text-muted-foreground',
meta: 'text-sm text-muted-foreground',
// As tall as the name's own line box, so at `items-start` the menu lands on
// the username's line rather than somewhere between the two lines of the
// author block — and lands there identically in all four variants.
action: 'h-6 shrink-0 items-center justify-center',
// `flex-1` for the same reason `Post.Author` has it: it is the header's
// flexible side, and without it the row is only as wide as the group's
// name — so `Post.Action` sits against the name instead of at the edge.
community: 'min-w-0 h-6 flex-1 flex-row items-center gap-2',
communityName: 'text-xs font-semibold text-card-foreground',
communityMeta: 'text-xs text-muted-foreground',
title: 'text-base font-semibold leading-snug text-card-foreground',
body: 'text-card-foreground',
media: 'overflow-hidden bg-muted',
footer: 'flex-row items-center',
stat: 'flex-row items-center gap-1.5 rounded-full',
statLabel: 'text-sm text-muted-foreground',
votes: 'flex-row items-center gap-0.5 rounded-full bg-muted',
voteButton: 'h-8 w-8 items-center justify-center rounded-full',
voteScore: 'text-sm font-semibold tabular-nums',
},
variants: {
variant: {
/** The full card — author, body, media, counts. */
feed: {
header: 'p-4 pb-3',
body: 'px-4 pb-3 text-base leading-relaxed',
media: 'mx-4 rounded-xl',
footer: 'gap-5 p-4',
},
/** A headline with a score beside it, the way a ranked community reads. */
vote: {
header: 'px-4 pb-2 pt-3.5',
body: 'px-4 pb-3 text-sm leading-relaxed',
media: 'mx-4 rounded-xl',
footer: 'gap-2 px-4 pb-3.5',
},
/** No media, name and handle on one line — for a dense timeline. */
compact: {
root: 'rounded-xl',
header: 'p-3.5 pb-2',
body: 'px-3.5 pb-2.5 text-sm leading-relaxed',
media: 'mx-3.5 rounded-lg',
footer: 'gap-6 px-3.5 pb-3',
},
/** The image is the card; the author is laid over it. */
media: {
header: 'absolute inset-x-0 top-0 z-10 p-3',
body: 'px-4 pb-3 pt-3 text-sm leading-relaxed',
media: 'rounded-none',
footer: 'gap-5 px-4 pb-4',
},
},
size: {
default: {},
sm: { name: 'text-sm', meta: 'text-xs', statLabel: 'text-xs' },
},
},
defaultVariants: {
variant: 'feed',
size: 'default',
},
});
type PostVariant = 'feed' | 'vote' | 'compact' | 'media';
const PostContext = createContext<{ variant: PostVariant; size: 'default' | 'sm' }>({
variant: 'feed',
size: 'default',
});
function usePost() {
return useContext(PostContext);
}
export interface PostProps
extends Omit,
VariantProps {
className?: string;
/** Which of the four shapes. */
variant?: PostVariant;
/** `sm` tightens the type for a card in a sidebar or a preview. */
size?: 'default' | 'sm';
/** Opening the post itself. The parts inside keep their own presses. */
onPress?: PressableProps['onPress'];
children?: ReactNode;
}
const PostRoot = forwardRef(
({ className, variant = 'feed', size = 'default', onPress, children, ...props }, ref) => {
const { root } = postVariants({ variant, size });
const context = useMemo(() => ({ variant, size }), [variant, size]);
const body = (
{children}
);
if (!onPress) {
return (
{body}
);
}
return (
// `article` rather than `button`: the card opens, but the row of controls
// inside it is the reason anyone is here, and announcing the whole thing
// as one button buries them.
{body}
);
}
);
PostRoot.displayName = 'Post';
/* -------------------------------------------------------------------------- */
/* Header */
/* -------------------------------------------------------------------------- */
export interface PostHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The author row. `Post.Action` is pulled to the trailing edge, so the overflow
* menu stays in the corner as a long display name wraps rather than riding down
* with it.
*/
const PostHeader = forwardRef(
({ className, children, ...props }, ref) => {
const { variant, size } = usePost();
const { header } = postVariants({ variant, size });
const content: ReactNode[] = [];
const actions: ReactNode[] = [];
for (const child of Array.isArray(children) ? children : [children]) {
if (isType(child, PostAction)) actions.push(child);
else content.push(child);
}
return (
{content}
{actions}
);
}
);
PostHeader.displayName = 'Post.Header';
function isType(child: ReactNode, type: unknown): boolean {
return (
typeof child === 'object' &&
child !== null &&
'type' in child &&
(child as { type?: unknown }).type === type
);
}
export interface PostAuthorProps extends Omit {
className?: string;
/** Display name. */
name: string;
/** `@handle`, shown beside the name in `compact` and under it elsewhere. */
handle?: string;
avatar?: ImageSourcePropType;
/** Initials behind a missing or broken avatar. */
fallback?: string;
/** Draws the verification rosette after the name. */
verified?: boolean;
/** "Posted 3m ago" — whatever the caller wants to call the time. */
timestamp?: ReactNode;
children?: ReactNode;
}
/**
* Who posted it.
*
* The handle sits beside the name in `compact` and beneath it everywhere else,
* which is the difference between a timeline row and a card: a dense row cannot
* afford a second line, and a card looks starved without one.
*/
const PostAuthor = forwardRef(
(
{ className, name, handle, avatar, fallback, verified, timestamp, children, ...props },
ref
) => {
const { variant, size } = usePost();
const { author, authorLine, name: nameSlot, handle: handleSlot, meta } = postVariants({
variant,
size,
});
const verifiedColor = useCSSVariable('--color-primary');
const onVerified = useCSSVariable('--color-primary-foreground');
const inline = variant === 'compact';
const overlaid = variant === 'media';
return (
// Top-aligned, not centred. The name is the first line of the block, and
// aligning to the top is what puts it — and therefore the menu opposite
// it — on the same rule in every variant, whether the block below the
// name is one line, two, or none.
{/* `h-6` fixes the name's line box, so the block below it starts at
the same offset whatever the type scale rounds the line height
to — which is what the menu opposite is aligning against. */}
{name}
{verified ? (
) : null}
{inline && handle ? (
{handle}
) : null}
{inline && timestamp !== undefined ? (
<>
·
{textChildren(timestamp, (text) => (
{text}
))}
>
) : null}
{!inline && (handle || timestamp !== undefined) ? (
{[handle, typeof timestamp === 'string' ? timestamp : undefined]
.filter(Boolean)
.join(' · ')}
) : null}
{!inline && timestamp !== undefined && typeof timestamp !== 'string'
? timestamp
: null}
{children}
);
}
);
PostAuthor.displayName = 'Post.Author';
/** First letters of the first two words — enough to tell two people apart. */
function initials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] ?? '')
.join('')
.toUpperCase();
}
export interface PostActionProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The header's trailing slot — an overflow menu, a follow button, a badge. */
const PostAction = forwardRef(
({ className, children, ...props }, ref) => {
const { variant, size } = usePost();
const { action } = postVariants({ variant, size });
return (
{children}
);
}
);
PostAction.displayName = 'Post.Action';
export interface PostCommunityProps extends Omit {
className?: string;
/** The group's name — "r/reactnative", "#design". */
name: string;
avatar?: ImageSourcePropType;
/** How long ago, and anything else that belongs on the line. */
meta?: ReactNode;
children?: ReactNode;
}
/**
* The group a post was made in, above its headline.
*
* Smaller and quieter than an author row, because in a ranked community the
* post's subject is the headline — the group is where it came from, not who
* said it.
*/
const PostCommunity = forwardRef(
({ className, name, avatar, meta, children, ...props }, ref) => {
const { variant, size } = usePost();
const {
community,
communityName,
communityMeta,
} = postVariants({ variant, size });
return (
{name}
{meta !== undefined ? (
<>
·
{textChildren(meta, (text) => (
{text}
))}
>
) : null}
{children}
);
}
);
PostCommunity.displayName = 'Post.Community';
/* -------------------------------------------------------------------------- */
/* Body */
/* -------------------------------------------------------------------------- */
export interface PostTitleProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The headline of a post whose subject is a headline rather than a person. */
const PostTitle = forwardRef(
({ className, children, ...props }, ref) => {
const { variant, size } = usePost();
const { title } = postVariants({ variant, size });
return (
{textChildren(children, (text) => {text})}
);
}
);
PostTitle.displayName = 'Post.Title';
export interface PostBodyProps extends Omit {
className?: string;
/** How many lines before it is cut off. Unlimited by default. */
numberOfLines?: number;
/** Called with the tag, without its `#`. Makes hashtags pressable. */
onTagPress?: (tag: string) => void;
/** Called with the handle, without its `@`. */
onMentionPress?: (handle: string) => void;
children?: ReactNode;
}
/**
* What was written.
*
* Hashtags and mentions inside a string child are picked out and coloured. They
* are found in the text rather than declared, because a post body arrives as
* one string from wherever it was typed — asking the caller to pre-split it
* would mean every caller writing the same tokeniser.
*
* A non-string child is left entirely alone: it has already been laid out by
* someone who knew more than we do.
*/
const PostBody = forwardRef(
(
{ className, numberOfLines, onTagPress, onMentionPress, children, ...props },
ref
) => {
const { variant, size } = usePost();
const { body } = postVariants({ variant, size });
if (typeof children !== 'string') {
return (
{children}
);
}
return (
{tokenize(children).map((token, index) =>
token.kind === 'text' ? (
token.value
) : (
onTagPress(token.value.slice(1)))
: onMentionPress && (() => onMentionPress(token.value.slice(1)))
}
>
{token.value}
)
)}
);
}
);
PostBody.displayName = 'Post.Body';
interface Token {
kind: 'text' | 'tag' | 'mention';
value: string;
}
/**
* Splits a body into plain runs, hashtags and mentions.
*
* The tag pattern stops at whitespace and at punctuation that ends a sentence,
* so "#DebtSnowball." highlights the tag and leaves the full stop black — the
* naive `\S+` swallows it and the sentence loses its ending.
*/
function tokenize(text: string): Token[] {
const pattern = /[#@][\w-]+/g;
const tokens: Token[] = [];
let last = 0;
for (const match of text.matchAll(pattern)) {
const start = match.index ?? 0;
if (start > last) tokens.push({ kind: 'text', value: text.slice(last, start) });
tokens.push({
kind: match[0].startsWith('#') ? 'tag' : 'mention',
value: match[0],
});
last = start + match[0].length;
}
if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) });
return tokens;
}
export interface PostMediaProps extends Omit {
className?: string;
source: ImageSourcePropType;
/** Width over height. `16 / 10` by default — wide enough not to eat the feed. */
aspectRatio?: number;
/**
* Darkens an edge of the image so type laid over it stays legible.
*
* A gradient rather than a panel: a flat rectangle over the top of a
* photograph has an edge of its own, and that edge reads as a bar covering
* the picture rather than as shading. `media` posts default to `top`, where
* the author sits; everything else to `none`.
*/
scrim?: 'none' | 'top' | 'bottom' | 'both';
/** Laid over the image: an expand affordance, a duration, a gallery count. */
overlay?: ReactNode;
/** Described for a screen reader. An image with nothing to say is decorative. */
alt?: string;
onPress?: PressableProps['onPress'];
children?: ReactNode;
}
/**
* Black, fading to nothing.
*
* Three stops rather than two: a straight linear ramp from 55% to nothing has a
* visible shoulder where it meets the picture, and the eye reads that shoulder
* as an edge — which is the whole thing a scrim exists to avoid.
*/
const SCRIM_COLOURS: readonly [string, string, string] = [
'rgba(0,0,0,0.55)',
'rgba(0,0,0,0.28)',
'rgba(0,0,0,0)',
];
/**
* The picture.
*
* A fixed aspect ratio rather than the image's own, because a feed of cards
* whose heights are decided by whatever was uploaded scrolls like a broken
* staircase. The image covers the box and is cropped by it.
*/
const PostMedia = forwardRef(
(
{
className,
source,
aspectRatio = 16 / 10,
scrim,
overlay,
alt,
onPress,
children,
...props
},
ref
) => {
const { variant, size } = usePost();
const { media } = postVariants({ variant, size });
// The media variant lays the author over the image, so it needs shading by
// default — the caller should not have to remember what makes their own
// layout readable.
const shade = scrim ?? (variant === 'media' ? 'top' : 'none');
const picture = (
<>
{shade === 'top' || shade === 'both' ? (
) : null}
{shade === 'bottom' || shade === 'both' ? (
) : null}
{overlay ? (
{overlay}
) : null}
{children}
>
);
if (!onPress) {
return (
{picture}
);
}
return (
{picture}
);
}
);
PostMedia.displayName = 'Post.Media';
/* -------------------------------------------------------------------------- */
/* Footer */
/* -------------------------------------------------------------------------- */
export interface PostFooterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** The row of counts and controls under the post. */
const PostFooter = forwardRef(
({ className, children, ...props }, ref) => {
const { variant, size } = usePost();
const { footer } = postVariants({ variant, size });
return (
{children}
);
}
);
PostFooter.displayName = 'Post.Footer';
/** What lighting up means. A like is red, a save is the accent, a repost green. */
const statTone: Record<'default' | 'like' | 'save' | 'repost', string> = {
default: '--color-primary',
like: '--color-destructive',
save: '--color-primary',
repost: '--color-success',
};
export interface PostStatProps extends Omit {
className?: string;
/** The icon component itself, not an element — it is re-rendered on toggle. */
icon?: ComponentType;
/** The number, or a word where a number would be meaningless ("Save"). */
value?: ReactNode;
/** Lit, and filled. */
active?: boolean;
/** Which colour "lit" is. */
tone?: 'default' | 'like' | 'save' | 'repost';
/** Pushes this stat and everything after it to the trailing edge. */
align?: 'start' | 'end';
children?: ReactNode;
}
/**
* One count in the footer, and the control that changes it.
*
* Pressing it pops the icon past its own size and settles back, fills it, and
* animates the value: the old number leaves upwards and the new one arrives
* from below when the count goes up, and the other way when it comes down.
* Repainting the digits in place is indistinguishable from a re-render, which
* is the doubt this exists to remove.
*/
const PostStat = forwardRef(
(
{
className,
icon: Icon,
value,
active = false,
tone = 'default',
align = 'start',
onPress,
children,
...props
},
ref
) => {
const { variant, size } = usePost();
const { stat, statLabel } = postVariants({ variant, size });
const litColor = useCSSVariable(statTone[tone]);
const restColor = useCSSVariable('--color-muted-foreground');
const reducedMotion = useReducedMotion();
const pop = useSharedValue(1);
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
if (reducedMotion) return;
// Past 1 and back. A toggle that only grows reads as a state that got
// bigger; one that overshoots reads as a press.
pop.value = withSequence(
withTiming(active ? 1.25 : 0.85, { duration: 90 }),
withSpring(1, POP)
);
}, [active, reducedMotion, pop]);
const iconStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value }] }));
const colour = active
? typeof litColor === 'string'
? litColor
: '#e11d48'
: typeof restColor === 'string'
? restColor
: '#737373';
const inner = (
<>
{Icon ? (
) : null}
{value !== undefined ? (
) : null}
{children}
>
);
const classes = stat({ className: cn(align === 'end' && 'ms-auto', className) });
if (!onPress) {
return (
{inner}
);
}
return (
{
selectionTick();
onPress(event);
}}
className={classes}
{...props}
>
{inner}
);
}
);
PostStat.displayName = 'Post.Stat';
/**
* A number that moves when it changes.
*
* The new value arrives from the direction the count travelled — rising into
* place when it went up, dropping in when it came down — while the old one
* fades. `215 → 216` then reads as a count going up rather than as text being
* replaced, which is the doubt worth removing: repainting digits in place is
* indistinguishable from a re-render.
*
* Only the entering half carries the direction. The element leaving was
* rendered before anyone knew which way the number would go, so asking it to
* animate knowingly would mean knowing the future.
*
* A value that is not a number — "Save" — cross-fades, since there is no
* direction for a word to go.
*/
function PostCount({
value,
className,
color,
}: {
value: ReactNode;
className?: string;
color?: string;
}) {
const reducedMotion = useReducedMotion();
// Read during the render the value changes on, not after it: an effect would
// settle the direction one frame after the animation had already started.
const before = numeric(usePrevious(value));
const after = numeric(value);
const direction =
before === null || after === null || before === after ? 0 : after > before ? 1 : -1;
const label = (
{value}
);
if (reducedMotion) return label;
return (
{label}
);
}
/** The value as a number, where it is one. `"5,874"` counts; `"Save"` does not. */
function numeric(value: ReactNode): number | null {
if (typeof value === 'number') return value;
if (typeof value !== 'string') return null;
const parsed = Number(value.replace(/[,\s]/g, ''));
return Number.isFinite(parsed) ? parsed : null;
}
/* -------------------------------------------------------------------------- */
/* Votes */
/* -------------------------------------------------------------------------- */
export type PostVote = 'up' | 'down' | null;
export interface PostVotesProps extends Omit {
className?: string;
/** The score as it stands, with the reader's own vote already in it. */
score: number | string;
/** Which way this reader voted, if either. */
vote?: PostVote;
/**
* Called with the new vote. Pressing the arrow already cast clears it, so
* `null` arrives as often as the other two — a vote you cannot take back is
* a vote people hesitate over.
*/
onVote?: (vote: PostVote) => void;
/** `vertical` stacks the arrows beside a thumbnail, the way a ranked list reads. */
orientation?: 'horizontal' | 'vertical';
disabled?: boolean;
}
/**
* The score, and the two arrows that move it.
*
* Both arrows fill and take a colour when cast, and the whole pill tints with
* them — the arrow alone is 16pt of a card, which is not enough to notice from
* arm's length. The score rolls in the direction of the change.
*
* Pressing the arrow already cast clears the vote. This is not a nicety: a
* score is a thing people change their minds about, and a vote that cannot be
* withdrawn is one they think twice before casting.
*/
const PostVotes = forwardRef(
(
{
className,
score,
vote = null,
onVote,
orientation = 'horizontal',
disabled = false,
...props
},
ref
) => {
const { variant, size } = usePost();
const { votes, voteButton, voteScore } = postVariants({ variant, size });
const upColor = useCSSVariable('--color-success');
const downColor = useCSSVariable('--color-destructive');
const restColor = useCSSVariable('--color-muted-foreground');
const up = typeof upColor === 'string' ? upColor : '#16a34a';
const down = typeof downColor === 'string' ? downColor : '#dc2626';
const rest = typeof restColor === 'string' ? restColor : '#737373';
const cast = (next: 'up' | 'down') => {
selectionTick();
onVote?.(vote === next ? null : next);
};
const scoreColour = vote === 'up' ? up : vote === 'down' ? down : undefined;
return (
cast('up')}
/>
cast('down')}
/>
);
}
);
PostVotes.displayName = 'Post.Votes';
/**
* One arrow.
*
* It travels along its own axis as it is cast — up for the up arrow, down for
* the down one — and springs back. The direction of the throw is the whole
* point: an arrow that merely scales says *something happened*, one that moves
* the way it points says *which way*.
*/
function VoteArrow({
direction,
active,
color,
disabled,
className,
onPress,
}: {
direction: 'up' | 'down';
active: boolean;
color: string;
disabled: boolean;
className: string;
onPress: () => void;
}) {
const reducedMotion = useReducedMotion();
const shift = useSharedValue(0);
const scale = useSharedValue(1);
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
if (reducedMotion) return;
const travel = direction === 'up' ? -5 : 5;
shift.value = withSequence(
withTiming(active ? travel : -travel * 0.4, { duration: 100 }),
withSpring(0, POP)
);
scale.value = withSequence(
withTiming(active ? 1.2 : 0.9, { duration: 100 }),
withSpring(1, POP)
);
}, [active, direction, reducedMotion, shift, scale]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: shift.value }, { scale: scale.value }],
}));
const Arrow = direction === 'up' ? ArrowUpIcon : ArrowDownIcon;
return (
);
}
export const Post = Object.assign(PostRoot, {
Header: PostHeader,
Author: PostAuthor,
Action: PostAction,
Community: PostCommunity,
Title: PostTitle,
Body: PostBody,
Media: PostMedia,
Footer: PostFooter,
Stat: PostStat,
Votes: PostVotes,
});