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:
- The reply button that appears on hover or in the message options
- The reply preview that shows above the message input
- 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:
- 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;
- 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:
- Thread views for long reply chains
- Reply counts to show how many responses a message has received
- "Jump to latest" button when viewing an old reply
- Notification when someone replies to your message
- 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.