diff --git a/src/components/messageStream/CollapsibleText.tsx b/src/components/messageStream/CollapsibleText.tsx new file mode 100644 index 0000000..b0320a4 --- /dev/null +++ b/src/components/messageStream/CollapsibleText.tsx @@ -0,0 +1,127 @@ +import { AnimatePresence, m } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { useI18n } from "../../i18n"; +import { EASE_OUT } from "../../motion"; + +/** + * Collapsible long-text container. + * + * When the rendered text overflows `collapsedLines`: + * 1. The text is clipped to a clean line boundary. + * 2. A soft gradient fade hints that more content is below. + * 3. A centered pill button with a chevron icon offers a clear tap target. + * + * The clipped height is `collapsedLines × computed line-height` so the cut + * always lands at a line boundary — no half-cut characters. + * + * Motion uses the shared `EASE_OUT` curve and respects MotionProvider's + * global `reducedMotion="user"`. + */ +export function CollapsibleText({ + children, + className = "", + wrapperClassName = "", + collapsedLines = 8, +}: { + children: ReactNode; + /** Typography classes applied to the text container. */ + className?: string; + /** Outer wrapper classes (padding, etc.). */ + wrapperClassName?: string; + collapsedLines?: number; +}) { + const { t } = useI18n(); + const innerRef = useRef(null); + const [clampedHeight, setClampedHeight] = useState(0); + const [fullHeight, setFullHeight] = useState(0); + const [needsToggle, setNeedsToggle] = useState(false); + const [expanded, setExpanded] = useState(false); + + const measure = () => { + const el = innerRef.current; + if (!el) return; + const lh = parseFloat(getComputedStyle(el).lineHeight) || 24; + const ch = Math.round(lh * collapsedLines); + const fh = el.scrollHeight; + setClampedHeight(ch); + setFullHeight(fh); + setNeedsToggle(fh > ch + 4); + }; + + useLayoutEffect(() => { + measure(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + if (innerRef.current) ro.observe(innerRef.current); + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + useEffect(() => { + const id = window.requestAnimationFrame(measure); + return () => window.cancelAnimationFrame(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [children, collapsedLines]); + + const isClamped = needsToggle && !expanded; + const targetHeight = needsToggle + ? expanded + ? fullHeight + : clampedHeight + : undefined; + + return ( +
+ +
+ {children} +
+ + {/* Soft fade hints "more content below" when clamped. */} + + {isClamped ? ( + + ) : null} + +
+ + {needsToggle ? ( + + ) : null} +
+ ); +} diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 42091bc..e8f60b9 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -62,7 +62,9 @@ export function MessageStream({ scope }: MessageStreamProps) { } } }, - { rootMargin: "200px" }, + // Prefetch the next page well before the sentinel is visible so content + // is already in place as the user scrolls — no blank gap at the bottom. + { rootMargin: "1000px" }, ); io.observe(el); return () => io.disconnect(); diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 2347969..b7bd034 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -5,6 +5,7 @@ import { BubbleImage } from "../BubbleImage"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; const MAX_VISIBLE = 4; @@ -71,9 +72,12 @@ export function AlbumBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index 864e07f..ada2c61 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -11,6 +11,7 @@ import { } from "../utils/filenameDisplay"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; import { useToast } from "../../Toast"; function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { @@ -97,9 +98,9 @@ export function FileDocBubble({ post }: { post: Post }) { ))} {text ? ( -
+ {text} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index a003fd7..a46c7b2 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -5,6 +5,7 @@ import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; export function ImageWithTextBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); @@ -30,9 +31,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) { {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/components/messageStream/bubbles/TextBubble.tsx b/src/components/messageStream/bubbles/TextBubble.tsx index bfb8523..abc1094 100644 --- a/src/components/messageStream/bubbles/TextBubble.tsx +++ b/src/components/messageStream/bubbles/TextBubble.tsx @@ -2,12 +2,13 @@ import type { Post } from "../../../types/post"; import { useI18n } from "../../../i18n"; import { autolink } from "../utils/autolink"; import { postDisplayText } from "../utils/postText"; +import { CollapsibleText } from "../CollapsibleText"; export function TextBubble({ post }: { post: Post }) { const { lang } = useI18n(); return ( -
+ {autolink(postDisplayText(post, lang))} -
+ ); } diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 9832d4b..440b384 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -7,6 +7,7 @@ import type { Attachment, Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; +import { CollapsibleText } from "../CollapsibleText"; import { downloadAttachment } from "../utils/downloadFile"; import { formatBytes } from "../utils/formatBytes"; import { postDisplayText } from "../utils/postText"; @@ -355,9 +356,12 @@ export function VideoBubble({ post }: { post: Post }) { })} {text ? ( -
+ {autolink(text)} -
+ ) : null} {listOpen ? ( {text ? ( -
+ {autolink(text)} -
+ ) : null} ); diff --git a/src/i18n.tsx b/src/i18n.tsx index caddf20..72f1887 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -45,6 +45,8 @@ const zhDict: Dict = { downloading: "下载中…", downloadOk: "下载完成", downloadFail: "下载失败,请重试", + showMore: "展开全部", + showLess: "收起全部", share: "分享", langLabel: "语言", admin: "后台", @@ -175,6 +177,8 @@ const enDict: Dict = { downloading: "Downloading…", downloadOk: "Download complete", downloadFail: "Download failed, please retry", + showMore: "Show all", + showLess: "Show less", share: "Share", langLabel: "Language", admin: "Admin", @@ -291,6 +295,8 @@ const languageNames: Record = { }, ja: { brand: "ARK ライブラリー", + showMore: "すべて表示", + showLess: "閉じる", lang_zh_CN: "中国語", lang_en: "英語", lang_ja: "日本語", @@ -301,6 +307,8 @@ const languageNames: Record = { }, ko: { brand: "ARK 라이브러리", + showMore: "모두 보기", + showLess: "접기", lang_zh_CN: "중국어", lang_en: "영어", lang_ja: "일본어", @@ -311,6 +319,8 @@ const languageNames: Record = { }, vi: { brand: "Thư viện ARK", + showMore: "Xem tất cả", + showLess: "Thu gọn", lang_zh_CN: "Tiếng Trung", lang_en: "Tiếng Anh", lang_ja: "Tiếng Nhật", @@ -321,6 +331,8 @@ const languageNames: Record = { }, id: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Tionghoa", lang_en: "Bahasa Inggris", lang_ja: "Bahasa Jepang", @@ -331,6 +343,8 @@ const languageNames: Record = { }, ms: { brand: "Perpustakaan ARK", + showMore: "Lihat semua", + showLess: "Tutup", lang_zh_CN: "Bahasa Cina", lang_en: "Bahasa Inggeris", lang_ja: "Bahasa Jepun", diff --git a/src/motion/Reveal.tsx b/src/motion/Reveal.tsx index 8c367c0..f527361 100644 --- a/src/motion/Reveal.tsx +++ b/src/motion/Reveal.tsx @@ -11,6 +11,13 @@ type RevealProps = { once?: boolean; /** Fraction of the element that must be visible to trigger (0-1). */ amount?: number; + /** + * Root margin for the in-view trigger (same semantics as IntersectionObserver + * `rootMargin`). The default positive bottom value reveals each block ~300px + * *before* it scrolls into view, so long feeds never show a blank gap that + * reads as "the bottom" before content fades in. + */ + margin?: string; }; /** @@ -24,6 +31,7 @@ export function Reveal({ className, once = true, amount = 0.15, + margin = "0px 0px 300px 0px", }: RevealProps) { return ( {children}