gs4f2wcln: Fit long text-only posts to the slide and add Read more and more/less for long post text on mobile

This commit is contained in:
2026-10-20 10:12:01 -06:00
parent d653e8e75d
commit 6af225afa5
6 changed files with 477 additions and 28 deletions
+42 -10
View File
@@ -6,6 +6,7 @@ import { GrowButton, SweepButton } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { PlayIcon, PostMenu } from "./PostCard";
import { VideoPlayer } from "./VideoPlayer";
import { CaptionText, QuoteText } from "./SlideText";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { Logo } from "./Logo";
import { useTheme } from "@/lib/use-theme";
@@ -41,7 +42,7 @@ export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_
export function isTapTarget(target: EventTarget | null): boolean {
const el = target as Element | null;
if (!el || typeof el.closest !== "function") return true;
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']");
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem'], [data-expanded-text]");
}
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
@@ -185,13 +186,36 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
);
}
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
function PostSlide({
post,
index,
current,
p,
onExpandedChange,
}: {
post: CardPost;
index: number;
current: boolean;
p: FeedViewProps;
onExpandedChange?: (index: number, open: boolean) => void;
}) {
const items = post.media.filter((m) => m.kind === "video" || m.src);
const media = items[0] ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
// While a video plays, the author and text step aside so the native controls stay clear.
const [playing, setPlaying] = useState(false);
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
const [textOpen, setTextOpen] = useState(false);
useEffect(() => {
if (!current) setTextOpen(false);
}, [current]);
useEffect(() => {
onExpandedChange?.(index, textOpen);
}, [index, textOpen, onExpandedChange]);
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
const cls =
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
`${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
const lastTap = useRef<number | null>(null);
const [burst, setBurst] = useState(0);
useEffect(() => {
@@ -227,9 +251,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
<div className="slide-scrim" aria-hidden="true" />
</>
) : (
<div className="slide-quote-w">
<p className="slide-quote">{post.text}</p>
</div>
<QuoteText text={post.text} open={textOpen} onOpenChange={setTextOpen} />
)}
{burst ? (
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
@@ -251,7 +273,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
</div>
</div>
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
{media && post.text ? <CaptionText text={post.text} open={textOpen} onOpenChange={setTextOpen} /> : null}
</div>
</section>
);
@@ -348,6 +370,13 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
export function MobileFeed(p: FeedViewProps) {
const reel = useRef<HTMLDivElement | null>(null);
const [current, setCurrent] = useState(0);
// The slide whose long text is open: the feed holds still so swipes scroll the text.
const [expandedAt, setExpandedAt] = useState<number | null>(null);
const locked = useRef(false);
locked.current = expandedAt !== null;
const onExpandedChange = useCallback((at: number, open: boolean) => {
setExpandedAt((cur) => (open ? at : cur === at ? null : cur));
}, []);
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
const closeSheet = useCallback(() => setSheet(null), []);
@@ -401,6 +430,8 @@ export function MobileFeed(p: FeedViewProps) {
let lastStep = 0;
let from = 0;
const onWheel = (e: WheelEvent) => {
// Open text scrolls natively; the reel is overflow-hidden meanwhile.
if (locked.current) return;
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
e.preventDefault();
const now = performance.now();
@@ -412,11 +443,11 @@ export function MobileFeed(p: FeedViewProps) {
};
let startY: number | null = null;
const onTouchStart = (e: TouchEvent) => {
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
startY = e.touches.length === 1 && !locked.current ? e.touches[0].clientY : null;
from = at();
};
const onTouchEnd = (e: TouchEvent) => {
if (startY === null || e.touches.length) return;
if (startY === null || e.touches.length || locked.current) return;
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
startY = null;
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
@@ -465,6 +496,7 @@ export function MobileFeed(p: FeedViewProps) {
className="reel"
data-testid="feed-reel"
data-current={index}
data-locked={expandedAt !== null ? "true" : undefined}
ref={(el) => {
reel.current = el;
setRef(p.listRef, el);
@@ -483,7 +515,7 @@ export function MobileFeed(p: FeedViewProps) {
) : null}
{p.status === "ready"
? p.posts.flatMap((post, i) => {
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} />;
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} onExpandedChange={onExpandedChange} />;
return i === featuredAt - 1
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
: [node];
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */
export const QUOTE_SIZES = [31, 26, 22, 19] as const;
/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */
export const QUOTE_MORE_RESERVE = 52;
export interface QuoteFit {
size: number;
/** Lines shown before "Read more"; 0 when the whole text fits. */
lines: number;
}
/**
* The first quote size whose rendered text fits, or the smallest size with as
* many whole lines as fit above the "Read more" button. `fits` measures the
* rendered text at a size; `linesAt` counts the lines that fit at the smallest one.
*/
export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit {
for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 };
const size = QUOTE_SIZES[QUOTE_SIZES.length - 1];
return { size, lines: Math.max(1, Math.floor(linesAt(size))) };
}
/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */
function useRemeasure(box: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
const el = box.current;
if (!el) return;
let frame = 0;
const later = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null;
ro?.observe(el);
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
void fonts?.ready.then(later);
fonts?.addEventListener?.("loadingdone", later);
const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null;
mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
mo?.disconnect();
fonts?.removeEventListener?.("loadingdone", later);
};
}, [box, measure]);
}
/**
* Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits
* between the top bar and the author block. When it still does not fit, it
* clamps and ends with "Read more", which opens the full text over the slide.
*/
export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const box = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
const quote = useRef<HTMLParagraphElement>(null);
const moreBtn = useRef<HTMLButtonElement>(null);
const closeBtn = useRef<HTMLButtonElement>(null);
const [fit, setFit] = useState<QuoteFit>({ size: QUOTE_SIZES[0], lines: 0 });
const measure = useCallback(() => {
const b = box.current;
const q = quote.current;
const w = inner.current;
if (!b || !q || !w || !b.clientHeight) return;
const apply = (f: QuoteFit) => {
q.style.fontSize = `${f.size}px`;
q.style.webkitLineClamp = f.lines ? String(f.lines) : "";
q.classList.toggle("is-clamped", f.lines > 0);
b.classList.toggle("is-clamped", f.lines > 0);
};
const result = pickQuoteFit(
(size) => {
apply({ size, lines: 0 });
return w.offsetHeight <= b.clientHeight + 0.5;
},
() => {
// Height left for the text: the box minus the quote mark, its gap and the button.
const other = w.offsetHeight - q.offsetHeight;
const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2;
return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight;
},
);
apply(result);
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
}, []);
useRemeasure(box, measure, [text]);
// Focus follows the reader: into it on open, back to "Read more" on close.
const wasOpen = useRef(false);
useEffect(() => {
if (open) closeBtn.current?.focus({ preventScroll: true });
else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true });
wasOpen.current = open;
}, [open]);
return (
<>
<div className={`slide-quote-w${fit.lines ? " is-clamped" : ""}`} ref={box} data-testid="slide-quote-box">
<div className="slide-quote-in" ref={inner}>
<span className="slide-quote-mark" aria-hidden="true">{"“"}</span>
<p
className={`slide-quote${fit.lines ? " is-clamped" : ""}`}
ref={quote}
data-fit-size={fit.size}
style={{ fontSize: `${fit.size}px`, WebkitLineClamp: fit.lines || undefined }}
>
{text}
</p>
</div>
{fit.lines ? (
<button
type="button"
className="slide-more slide-more-quote"
ref={moreBtn}
aria-expanded={open}
onClick={() => onOpenChange(true)}
>
Read more
</button>
) : null}
</div>
{open ? (
<div
className="slide-reader"
data-testid="slide-reader"
data-expanded-text="true"
role="region"
aria-label="Full post"
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
>
<div className="slide-reader-scroll" data-testid="slide-reader-scroll" tabIndex={0}>
<p className="slide-reader-text">{text}</p>
</div>
<button type="button" className="slide-more slide-reader-close" ref={closeBtn} aria-expanded="true" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null}
</>
);
}
/**
* Caption over a photo or video: three lines, then "more" when it is longer.
* Open, the whole caption shows over the media on a darker scrim, scrolling
* inside the slide when it is very long, with "less" to collapse it.
*/
export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const caption = useRef<HTMLParagraphElement>(null);
const [long, setLong] = useState(false);
const measure = useCallback(() => {
const c = caption.current;
// Measured only while clamped: an open caption is long by definition.
if (!c || c.classList.contains("is-open") || !c.clientHeight) return;
setLong(c.scrollHeight > c.clientHeight + 1);
}, []);
useRemeasure(caption, measure, [text]);
useEffect(() => {
if (!open && caption.current) caption.current.scrollTop = 0;
}, [open]);
return (
<>
<p
className={`slide-text${open ? " is-open" : ""}`}
ref={caption}
data-expanded-text={open ? "true" : undefined}
data-testid="slide-caption"
tabIndex={open ? 0 : undefined}
>
{text}
</p>
{long || open ? (
<button type="button" className="slide-more slide-more-caption" aria-expanded={open} onClick={() => onOpenChange(!open)}>
{open ? "less" : "more"}
</button>
) : null}
</>
);
}