Skip to main content

Reply System

I always find it frustrating when chat apps don't have a good reply feature. When conversations get busy, it's hard to tell which message someone is responding to. So I implemented a proper reply system that lets users respond directly to specific messages.

How It Works

The reply system has three main components:

  1. The reply button that appears on hover or in the message options
  2. The reply preview that shows above the message input
  3. The actual reply display in the message thread

Data Structure

Each message can optionally have a replyTo field that references the ID of another message:

{
"id": "abc123",
"content": "I agree with what you said!",
"sender": "userId",
"timestamp": 1650120000000,
"replyTo": "xyz789" // ID of the message being replied to
}

Message Reply UI

The reply button appears when you hover over a message or open the message options menu:

// In ChatArea.jsx
const renderMessageOptions = (message) => {
const isOwnMessage = message.sender === user?.uid;

return (
<div className={styles.messageOptions}>
<button
className={styles.replyButton}
onClick={() => handleReply(message)}
title="Reply"
>
↩️ Reply
</button>

{isOwnMessage && (
<>
<button
className={styles.editButton}
onClick={() => handleEdit(message)}
title="Edit"
>
✏️ Edit
</button>

<button
className={styles.deleteButton}
onClick={() => handleDelete(message)}
title="Delete"
>
🗑️ Delete
</button>
</>
)}
</div>
);
};

// When rendering each message
return (
<div
className={styles.message}
onMouseEnter={() => setHoveredMessageId(message.id)}
onMouseLeave={() => setHoveredMessageId(null)}
>
{/* Message content */}
{/* ... */}

{/* Reply button that shows on hover */}
{hoveredMessageId === message.id && (
<button
className={styles.hoverReplyButton}
onClick={() => handleReply(message)}
>
↩️
</button>
)}

{/* Other message options menu */}
{/* ... */}
</div>
);

Reply Handler

When a user clicks the reply button, we store the message being replied to:

// In ChatArea.jsx
const [replyingTo, setReplyingTo] = useState(null);

const handleReply = (message) => {
setReplyingTo(message);

// Focus the input after setting reply
if (messageInputRef.current) {
messageInputRef.current.focus();
}
};

Reply Preview Component

When replying to a message, I show a preview above the input:

// src/components/chat/ReplyPreview.jsx
import { useAuth } from '../../contexts/AuthContext';
import styles from './ReplyPreview.module.css';

export default function ReplyPreview({ message, onCancel }) {
const { user } = useAuth();

// Determine if this is your own message being replied to
const isOwnMessage = message.sender === user?.uid;

// Truncate long messages in the preview
const truncateContent = (content, maxLength = 50) => {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
};

return (
<div className={styles.replyPreview}>
<div className={styles.replyContent}>
<span className={styles.replyingTo}>
Replying to {isOwnMessage ? 'yourself' : message.senderName}:
</span>
<p className={styles.replyText}>
{truncateContent(message.content)}
</p>
</div>

<button
className={styles.cancelButton}
onClick={onCancel}
aria-label="Cancel reply"
>
×
</button>
</div>
);
}

The styling for the reply preview:

/* ReplyPreview.module.css */
.replyPreview {
display: flex;
align-items: center;
justify-content: space-between;
background-color: rgba(0, 0, 0, 0.05);
border-left: 4px solid #0084ff;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 4px;
}

.replyContent {
flex: 1;
overflow: hidden;
}

.replyingTo {
font-size: 12px;
color: #0084ff;
font-weight: 500;
display: block;
margin-bottom: 2px;
}

.replyText {
margin: 0;
font-size: 14px;
color: #4e4e4e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.cancelButton {
background: none;
border: none;
color: #6c757d;
font-size: 16px;
cursor: pointer;
padding: 0 0 0 8px;
}

Sending Replies

When sending a message with a reply, we pass the reply reference:

// In ChatArea.jsx
const handleSend = async (e) => {
e.preventDefault();

if (!newMessage.trim() || !currentChat?.id) return;

try {
// Send the message with the reply reference
await sendMessage(newMessage, replyingTo?.id || null);

// Clear the input and reply reference
setNewMessage('');
setReplyingTo(null);

} catch (error) {
console.error('Error sending message:', error);
}
};

Displaying Replies in the Chat

When rendering messages, I needed to show which message they're replying to:

// In MessageItem.jsx
function MessageItem({
message,
isOwnMessage,
onReply,
allMessages, // Needed to find the replied message
}) {
// Find the message being replied to
const repliedToMessage = useMemo(() => {
if (!message.replyTo || !allMessages) return null;

return allMessages.find(msg => msg.id === message.replyTo);
}, [message.replyTo, allMessages]);

return (
<div className={`${styles.messageItem} ${isOwnMessage ? styles.ownMessage : ''}`}>
{/* If this is a reply, show the replied message preview */}
{repliedToMessage && (
<div
className={styles.repliedMessage}
onClick={() => scrollToMessage(repliedToMessage.id)}
>
<span className={styles.repliedToName}>
{repliedToMessage.sender === message.sender
? 'Replied to themselves'
: `Replied to ${repliedToMessage.senderName}`}
</span>
<p className={styles.repliedToText}>
{truncateText(repliedToMessage.content, 60)}
</p>
</div>
)}

{/* The actual message content */}
<div className={styles.content}>
{message.content}
</div>

{/* Message footer with timestamp, etc */}
{/* ... */}
</div>
);
}

And the styling for the reply display:

/* MessageItem.module.css */
.repliedMessage {
background-color: rgba(0, 0, 0, 0.05);
border-left: 3px solid rgba(0, 132, 255, 0.5);
padding: 4px 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}

.repliedMessage:hover {
background-color: rgba(0, 0, 0, 0.08);
}

.repliedToName {
color: #0084ff;
font-weight: 500;
display: block;
margin-bottom: 2px;
}

.repliedToText {
margin: 0;
color: #4e4e4e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

Scrolling to the Original Message

To make the reply system more useful, I added a feature to scroll to the original message when a user clicks on a reply:

// In ChatArea.jsx
const messageRefs = useRef({});

// Function to register each message element by its ID
const registerMessageRef = (id, element) => {
messageRefs.current[id] = element;
};

// Function to scroll to a specific message
const scrollToMessage = (messageId) => {
const messageElement = messageRefs.current[messageId];

if (messageElement) {
messageElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});

// Highlight the message briefly
messageElement.classList.add(styles.highlightedMessage);
setTimeout(() => {
messageElement.classList.remove(styles.highlightedMessage);
}, 2000);
}
};

// In the message rendering loop
{messages.map(message => (
<div
key={message.id}
ref={el => registerMessageRef(message.id, el)}
className={`${styles.message} ${highlightedMessageId === message.id ? styles.highlightedMessage : ''}`}
>
{/* Message content */}
</div>
))}

The CSS for the highlighted message:

@keyframes highlight {
0% { background-color: rgba(0, 132, 255, 0.2); }
100% { background-color: transparent; }
}

.highlightedMessage {
animation: highlight 2s ease-out;
}

Technical Challenges

Finding Replied Messages

One challenge was efficiently finding the message being replied to. For a large chat, doing a find operation for each reply could be slow. I solved this a few ways:

  1. Using a Map for O(1) lookups instead of array traversal:
// In ChatArea.jsx - Processing messages
useEffect(() => {
// Create a map of messages by ID for efficient lookups
const messagesMap = messages.reduce((map, message) => {
map.set(message.id, message);
return map;
}, new Map());

setMessagesById(messagesMap);
}, [messages]);

// Later when rendering
const repliedToMessage = message.replyTo ? messagesById.get(message.replyTo) : null;
  1. Handling messages that aren't loaded yet:
// If the replied message isn't in our current view
if (message.replyTo && !messagesById.get(message.replyTo)) {
// Show "View original message" button instead
return (
<button
className={styles.viewOriginalButton}
onClick={() => fetchAndScrollToMessage(message.replyTo)}
>
View original message
</button>
);
}

Reply Chain Depth

Another issue was how to handle reply chains (replies to replies). I decided to allow any depth but limit the visual nesting:

// In MessageItem.jsx
// Calculate the reply depth (how many nested replies)
const calculateReplyDepth = (messageId, depth = 0, maxDepth = 3) => {
if (depth >= maxDepth) return depth;

const msg = messagesById.get(messageId);
if (!msg || !msg.replyTo) return depth;

return calculateReplyDepth(msg.replyTo, depth + 1, maxDepth);
};

const replyDepth = calculateReplyDepth(message.id);

return (
<div
className={`${styles.messageItem} ${styles[`replyDepth${Math.min(replyDepth, 3)}`]}`}
>
{/* Message content */}
</div>
);

Future Improvements

If I had more time, I'd enhance the reply system with:

  1. Thread views for long reply chains
  2. Reply counts to show how many responses a message has received
  3. "Jump to latest" button when viewing an old reply
  4. Notification when someone replies to your message
  5. Reply quoting for multiple messages at once

Overall, I'm pretty happy with how the reply system turned out. It makes conversations much easier to follow, especially in active group chats.