diff --git a/src/components/message/CollapsibleMessage.tsx b/src/components/message/CollapsibleMessage.tsx index d39a261c..f22fb125 100644 --- a/src/components/message/CollapsibleMessage.tsx +++ b/src/components/message/CollapsibleMessage.tsx @@ -1,68 +1,83 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; import type * as React from "react"; -import { useLayoutEffect, useRef, useState } from "react"; +import { + forwardRef, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { isScrolledToBottom } from "../../hooks/useScrollToBottom"; + +export interface CollapsibleMessageHandle { + toggle: () => void; +} interface CollapsibleMessageProps { content: React.ReactNode; maxLines?: number; + hoverOnly?: boolean; + onNeedsCollapsing?: (needs: boolean) => void; } -export const CollapsibleMessage: React.FC = ({ - content, - maxLines = 3, -}) => { +export const CollapsibleMessage = forwardRef< + CollapsibleMessageHandle, + CollapsibleMessageProps +>(({ content, maxLines = 5, hoverOnly = false, onNeedsCollapsing }, ref) => { const [isExpanded, setIsExpanded] = useState(false); const [needsCollapsing, setNeedsCollapsing] = useState(false); const [contentHeight, setContentHeight] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - const [isExpanding, setIsExpanding] = useState(false); + const [collapsedMaxHeight, setCollapsedMaxHeight] = useState("none"); const contentRef = useRef(null); - const animationTimeoutRef = useRef(null); - - // Cleanup timeout on unmount - useLayoutEffect(() => { - return () => { - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); - animationTimeoutRef.current = null; - } - }; - }, []); useLayoutEffect(() => { if (!contentRef.current) return; - // Measure the actual rendered content height const element = contentRef.current; const computedStyle = window.getComputedStyle(element); - const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 16; // fallback to 16px + const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 16; const maxHeight = lineHeight * maxLines; - // Get the full content height const fullHeight = element.scrollHeight; setContentHeight(fullHeight); + setCollapsedMaxHeight(`${lineHeight * maxLines}px`); - // Check if content overflows the max height - setNeedsCollapsing(fullHeight > maxHeight); - }, [maxLines]); + const needs = fullHeight > maxHeight; + setNeedsCollapsing(needs); + onNeedsCollapsing?.(needs); + }, [maxLines, onNeedsCollapsing]); const toggleExpanded = () => { const willExpand = !isExpanded; - setIsExpanding(willExpand); - setIsAnimating(true); - setIsExpanded(willExpand); - // Clear any existing timeout - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); + if (willExpand && contentRef.current) { + let scrollContainer: HTMLElement | null = null; + let el: HTMLElement | null = contentRef.current.parentElement; + while (el) { + if (el.scrollHeight > el.clientHeight) { + scrollContainer = el; + break; + } + el = el.parentElement; + } + + // Anchor scroll position during expand so users at the bottom stay there + if (scrollContainer && isScrolledToBottom(scrollContainer)) { + const container = scrollContainer; + const observer = new ResizeObserver(() => { + container.scrollTop = container.scrollHeight; + }); + observer.observe(contentRef.current); + // 350ms outlasts the 300ms CSS transition + setTimeout(() => observer.disconnect(), 350); + } } - // Reset animation after it completes - animationTimeoutRef.current = window.setTimeout(() => { - setIsAnimating(false); - animationTimeoutRef.current = null; - }, 600); + setIsExpanded(willExpand); }; + useImperativeHandle(ref, () => ({ toggle: toggleExpanded })); + return (
= ({ maxHeight: isExpanded ? `${contentHeight}px` : needsCollapsing - ? "4.5em" + ? collapsedMaxHeight : "none", }} > {content}
{needsCollapsing && ( -
-
-
+ <> + {!isExpanded &&
} +
-
-
+ )}
); -}; +}); + +CollapsibleMessage.displayName = "CollapsibleMessage"; diff --git a/src/components/message/MessageActions.tsx b/src/components/message/MessageActions.tsx index 682c69fa..6a622e3a 100644 --- a/src/components/message/MessageActions.tsx +++ b/src/components/message/MessageActions.tsx @@ -20,7 +20,7 @@ export const MessageActions: React.FC = ({ canReply = !!message.msgid, }) => { return ( -
+
{canRedact && onRedactClick && ( + )} -
+ ); })}
diff --git a/src/components/message/SwipeableMessage.tsx b/src/components/message/SwipeableMessage.tsx index 1399dc8d..93dc6e88 100644 --- a/src/components/message/SwipeableMessage.tsx +++ b/src/components/message/SwipeableMessage.tsx @@ -10,6 +10,7 @@ interface SwipeableMessageProps { onReply: () => void; onReact: (buttonElement: Element) => void; onDelete?: () => void; + onTap?: () => void; canReply: boolean; canDelete: boolean; isNarrowView: boolean; @@ -22,6 +23,7 @@ export const SwipeableMessage: React.FC = ({ onReply, onReact, onDelete, + onTap, canReply, canDelete, isNarrowView, @@ -30,6 +32,9 @@ export const SwipeableMessage: React.FC = ({ const [isSpringBack, setIsSpringBack] = useState(false); const [sheetOpen, setSheetOpen] = useState(false); const wrapperRef = useRef(null); + const touchStartTargetRef = useRef(null); + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null); + const hasMovedRef = useRef(false); const handleLongPress = useCallback(() => { setSheetOpen(true); @@ -114,14 +119,35 @@ export const SwipeableMessage: React.FC = ({ }} {...swipeEventHandlers} onTouchStartCapture={(e) => { + const touch = e.touches[0]; + touchStartTargetRef.current = e.target; + touchStartPosRef.current = { x: touch.clientX, y: touch.clientY }; + hasMovedRef.current = false; longPress.onTouchStart(e); }} onTouchMoveCapture={(e) => { + // Mirror useLongPress moveThreshold so a tap isn't cancelled by iOS micro-drift + if (touchStartPosRef.current) { + const t = e.touches[0]; + const dx = t.clientX - touchStartPosRef.current.x; + const dy = t.clientY - touchStartPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > 10) { + hasMovedRef.current = true; + } + } longPress.onTouchMove(e); }} onTouchEndCapture={() => { + // firedRef must be read before onTouchEnd clears it + const wasLongPress = longPress.firedRef.current; longPress.onTouchEnd(); releaseGesture(); + if (!wasLongPress && !hasMovedRef.current && onTap) { + const target = touchStartTargetRef.current as Element | null; + if (!target?.closest("button, [role='button'], a")) { + onTap(); + } + } }} onTouchCancelCapture={() => { longPress.onTouchCancel(); diff --git a/src/index.css b/src/index.css index b282c666..c75acd65 100644 --- a/src/index.css +++ b/src/index.css @@ -12,49 +12,6 @@ max-height: 4.5em; /* Fallback for content that doesn't respect line-clamp (like tables) */ } -/* Arrow flip animations */ -@keyframes arrow-flip-expand { - 0% { - transform: rotateX(0deg); - } - 100% { - transform: rotateX(180deg); - } -} - -@keyframes arrow-flip-collapse { - 0% { - transform: rotateX(180deg); - } - 100% { - transform: rotateX(0deg); - } -} - -.arrow-flip-expand { - animation: arrow-flip-expand 0.6s ease-in-out; -} - -.arrow-flip-collapse { - animation: arrow-flip-collapse 0.6s ease-in-out; -} - -/* Collapsible message truncation indicator */ -.collapsible-message .truncation-container { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - margin-top: 4px; -} - -.collapsible-message .truncation-line { - flex: 1; - height: 2px; - background-color: #9ca3af; - opacity: 0.6; - max-width: 100px; -} body { font-family: 'Roboto Mono', monospace; @@ -180,6 +137,42 @@ html, body, #root { } } +/* Hover reveal animation for message action buttons */ +@keyframes message-actions-appear { + 0% { + opacity: 0; + transform: translateY(12px) scale(0.82); + } + 60% { + opacity: 1; + transform: translateY(-3px) scale(1.04); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.group:hover .message-actions-container { + animation: message-actions-appear 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +/* Hover reveal animation for collapsible expand button */ +@keyframes collapsible-btn-appear { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.group:hover .collapsible-expand-btn { + animation: collapsible-btn-appear 0.15s ease-out forwards; +} + .shimmer { position: relative; overflow: hidden; diff --git a/tests/components/CollapsibleMessage.test.tsx b/tests/components/CollapsibleMessage.test.tsx new file mode 100644 index 00000000..3ba60e5b --- /dev/null +++ b/tests/components/CollapsibleMessage.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CollapsibleMessage } from "../../src/components/message/CollapsibleMessage"; + +describe("CollapsibleMessage", () => { + const LINE_HEIGHT = 20; + let mockScrollHeight = 0; + + beforeEach(() => { + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + lineHeight: `${LINE_HEIGHT}px`, + }) as unknown as CSSStyleDeclaration, + ); + + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return mockScrollHeight; + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + mockScrollHeight = 0; + }); + + it("does not show button when content fits within 5 lines", () => { + mockScrollHeight = LINE_HEIGHT * 5; // exactly at threshold + render(Short content

} />); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("shows button when content exceeds 5 lines", () => { + mockScrollHeight = LINE_HEIGHT * 6; // one line over + render(Long content

} />); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("toggles expanded state on button click", () => { + mockScrollHeight = LINE_HEIGHT * 6; + const { container } = render( + Long content

} />, + ); + + const contentDiv = container.querySelector( + ".overflow-hidden", + ) as HTMLElement; + expect(contentDiv.style.maxHeight).toBe(`${LINE_HEIGHT * 5}px`); + + fireEvent.click(screen.getByRole("button")); + + expect(contentDiv.style.maxHeight).toBe(`${LINE_HEIGHT * 6}px`); + }); + + it("uses px units (not em) for collapsed height", () => { + mockScrollHeight = LINE_HEIGHT * 6; + const { container } = render( + Long content

} />, + ); + + const contentDiv = container.querySelector( + ".overflow-hidden", + ) as HTMLElement; + const maxHeight = contentDiv.style.maxHeight; + + expect(maxHeight).toMatch(/^\d+px$/); + expect(maxHeight).not.toContain("em"); + }); + + it("shows correct tooltip before and after toggle", () => { + mockScrollHeight = LINE_HEIGHT * 6; + render(Long content

} />); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("title", "Read more"); + + fireEvent.click(button); + expect(button).toHaveAttribute("title", "Show less"); + }); +});