Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 69 additions & 55 deletions src/components/message/CollapsibleMessage.tsx
Original file line number Diff line number Diff line change
@@ -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<CollapsibleMessageProps> = ({
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<number | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [isExpanding, setIsExpanding] = useState(false);
const [collapsedMaxHeight, setCollapsedMaxHeight] = useState<string>("none");
const contentRef = useRef<HTMLDivElement>(null);
const animationTimeoutRef = useRef<number | null>(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]);
Comment thread
matheusfillipe marked this conversation as resolved.

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 (
<div className="collapsible-message">
<div
Expand All @@ -72,37 +87,36 @@ export const CollapsibleMessage: React.FC<CollapsibleMessageProps> = ({
maxHeight: isExpanded
? `${contentHeight}px`
: needsCollapsing
? "4.5em"
? collapsedMaxHeight
: "none",
}}
>
{content}
</div>
{needsCollapsing && (
<div className="truncation-container select-none">
<div className="truncation-line" />
<div className="mt-1 text-center">
<>
{!isExpanded && <div className="border-b border-white/20 mt-1" />}
<div
className={`flex justify-start mt-0.5 ${
hoverOnly ? "opacity-0 collapsible-expand-btn" : ""
}`}
>
<button
onClick={toggleExpanded}
className="text-blue-500 hover:text-blue-600 text-xs font-medium cursor-pointer border border-blue-500 rounded-full py-0 px-1"
style={{ textDecoration: "none" }}
title={isExpanded ? "Show less" : "Read more"}
className="opacity-80 hover:opacity-100 transition-opacity p-0.5 rounded"
>
{isExpanded ? "Show less " : "Show more "}
<span
className={`inline-block ${isAnimating ? (isExpanding ? "arrow-flip-expand" : "arrow-flip-collapse") : ""}`}
style={
!isAnimating && isExpanded
? { transform: "rotateX(180deg)" }
: undefined
}
>
</span>
<ChevronDownIcon
className={`w-5 h-5 text-discord-text-muted transition-transform duration-300 ${
isExpanded ? "rotate-180" : ""
}`}
/>
</button>
</div>
<div className="truncation-line" />
</div>
</>
)}
</div>
);
};
});

CollapsibleMessage.displayName = "CollapsibleMessage";
2 changes: 1 addition & 1 deletion src/components/message/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const MessageActions: React.FC<MessageActionsProps> = ({
canReply = !!message.msgid,
}) => {
return (
<div className="absolute bottom-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-2 select-none">
<div className="absolute bottom-1 right-2 opacity-0 message-actions-container flex space-x-2 select-none">
{canRedact && onRedactClick && (
<button
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded text-xs"
Expand Down
17 changes: 16 additions & 1 deletion src/components/message/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { openExternalUrl } from "../../lib/openUrl";
import useStore, { loadSavedMetadata } from "../../store";
import type { MessageType, PrivateChat, User } from "../../types";
import { EnhancedLinkWrapper } from "../ui/LinkWrapper";
import type { CollapsibleMessageHandle } from "./CollapsibleMessage";
import { InviteMessage } from "./InviteMessage";
import {
ActionMessage,
Expand Down Expand Up @@ -430,6 +431,8 @@ export const MessageItem = (props: MessageItemProps) => {
const pmUserCache = useRef(new Map<string, User>());
const isNarrowView = useMediaQuery();
const isTouchDevice = useMediaQuery("(pointer: coarse)");
const collapsibleRef = useRef<CollapsibleMessageHandle>(null);
const [messageNeedsCollapsing, setMessageNeedsCollapsing] = useState(false);

const ircCurrentUser = ircClient.getCurrentUser(message.serverId);
const isCurrentUser = ircCurrentUser?.username === message.userId;
Expand Down Expand Up @@ -610,7 +613,14 @@ export const MessageItem = (props: MessageItemProps) => {
);

// Create collapsible content wrapper
const collapsibleContent = <CollapsibleMessage content={htmlContent} />;
const collapsibleContent = (
<CollapsibleMessage
ref={collapsibleRef}
content={htmlContent}
hoverOnly={!isTouchDevice}
onNeedsCollapsing={setMessageNeedsCollapsing}
/>
);

const theme = localStorage.getItem("theme") || "discord";
const username = message.userId.split("-")[0];
Expand Down Expand Up @@ -894,6 +904,11 @@ export const MessageItem = (props: MessageItemProps) => {
onReply={() => setReplyTo(message)}
onReact={(el) => onReactClick(message, el)}
onDelete={canRedact ? () => onRedactMessage?.(message) : undefined}
onTap={
messageNeedsCollapsing && isTouchDevice
? () => collapsibleRef.current?.toggle()
: undefined
}
canReply={message.type === "message"}
canDelete={canRedact}
isNarrowView={isTouchDevice}
Expand Down
17 changes: 5 additions & 12 deletions src/components/message/MessageReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ export const MessageReactions: React.FC<MessageReactionsProps> = ({
{Object.entries(groupedReactions).map(([emoji, data]) => {
const reactionData = data as ReactionData;
return (
<div
<button
key={emoji}
type="button"
className="bg-discord-dark-300 hover:bg-discord-dark-200 text-white px-1.5 py-0.5 rounded text-xs flex items-center gap-1 transition-colors cursor-pointer group"
title={`${emoji} ${reactionData.count} ${reactionData.count === 1 ? "reaction" : "reactions"} by ${reactionData.users.join(", ")}`}
onClick={() =>
Expand All @@ -61,20 +62,12 @@ export const MessageReactions: React.FC<MessageReactionsProps> = ({
>
<span>{emoji}</span>
<span className="text-xs font-medium">{reactionData.count}</span>
{/* Show X button if current user reacted */}
{reactionData.currentUserReacted && (
<button
className="ml-1 text-red-400 hover:text-red-300 opacity-0 group-hover:opacity-100 transition-opacity text-xs"
onClick={(e) => {
e.stopPropagation();
onReactionClick(emoji, true);
}}
title="Remove reaction"
>
<span className="ml-1 text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-xs">
×
</button>
</span>
)}
</div>
</button>
);
})}
</div>
Expand Down
26 changes: 26 additions & 0 deletions src/components/message/SwipeableMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface SwipeableMessageProps {
onReply: () => void;
onReact: (buttonElement: Element) => void;
onDelete?: () => void;
onTap?: () => void;
canReply: boolean;
canDelete: boolean;
isNarrowView: boolean;
Expand All @@ -22,6 +23,7 @@ export const SwipeableMessage: React.FC<SwipeableMessageProps> = ({
onReply,
onReact,
onDelete,
onTap,
canReply,
canDelete,
isNarrowView,
Expand All @@ -30,6 +32,9 @@ export const SwipeableMessage: React.FC<SwipeableMessageProps> = ({
const [isSpringBack, setIsSpringBack] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const touchStartTargetRef = useRef<EventTarget | null>(null);
const touchStartPosRef = useRef<{ x: number; y: number } | null>(null);
const hasMovedRef = useRef(false);

const handleLongPress = useCallback(() => {
setSheetOpen(true);
Expand Down Expand Up @@ -114,14 +119,35 @@ export const SwipeableMessage: React.FC<SwipeableMessageProps> = ({
}}
{...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();
}
}
Comment thread
matheusfillipe marked this conversation as resolved.
}}
onTouchCancelCapture={() => {
longPress.onTouchCancel();
Expand Down
Loading
Loading