Building on the lightweight Slack alternative with smarter state management and Alpine.js
How I added unread indicators and future-proofed the chat system
Overview
In my last blog post From Company Updates to Real-Time Chat: Building a Lightweight Team Communication System (Mini Slack), I described transforming the company updates system into a real-time chat platform. Today, I want to share two significant improvements: the addition of smart unread indicators amongst many other visual improvements and a complete frontend rewrite using Alpine.js to replace our vanilla JavaScript implementation.
The Evolution
While the basic chat functionality worked well, we needed two key improvements:
- Better awareness of new messages across channels
- A more maintainable, reactive frontend architecture
This led to implementing unread indicators and adopting Alpine.js as our frontend framework.
Moving to Alpine.js
Before diving into the new features, it’s worth noting that I’ve completely rewritten the frontend using Alpine.js. This shift from vanilla JavaScript brings several advantages:
The declarative nature of Alpine.js has made our real-time features much easier to implement and maintain. Instead of manually managing DOM updates, we now have reactive state management built-in.
<div class="chat-container"
x-data="{
state: 'loading',
currentChannel: '{{ default_channel.name }}',
unreadCounts: {},
channelStates: {},
showPinnedOnly: false,
searchQuery: ''
}"
x-init="initializeApp">
The declarative nature of Alpine.js has made our real-time features much easier to implement and maintain. Instead of manually managing DOM updates, we now have reactive state management built-in.
Why the Enhancement?
While the basic chat functionality worked well, users need better awareness of new messages across channels. The key requirements were:
- Visual indicators for unread messages
- Accurate message counting
- Proper state management per user
- Support for future interaction types
The New Architecture
Generic Message State Model
Instead of creating separate tables for different types of message interactions (reads, likes, pins), I’ve implemented a flexible ChatMessageState
model:
class InteractionType(Enum):
READ = "read" # Track read status
REACTION = "reaction" # For emoji reactions
BOOKMARK = "bookmark" # For saving messages
THREAD_READ = "thread_read" # For thread read status
PIN = "pin" # For pinning messages
LIKE = "like" # For liking messages
EDIT = "edit" # Track edit history
DELETE = "delete" # Soft deletes?
@ModelRegistry.register
class ChatMessageState(db.Model):
__tablename__ = 'chat_message_state'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
message_id = db.Column(db.Integer, db.ForeignKey('chat.id'), nullable=False)
channel_id = db.Column(db.Integer, db.ForeignKey('channel.id'), nullable=False)
interaction_type = db.Column(db.Enum(InteractionType), nullable=False)
data = db.Column(db.JSON, nullable=True)
__table_args__ = (
db.UniqueConstraint('user_id', 'message_id', 'interaction_type'),
)
This unified approach offers several advantages:
- Single table for all message-related states
- Flexible JSON data field for interaction-specific information
- Easy addition of new interaction types
- Efficient querying across interaction types
Smart Unread Tracking
The unread tracking system now works by storing the last read message ID:
@classmethod
def mark_channel_read(cls, user_id, channel_id):
"""Mark all messages in a channel as read"""
try:
last_message = Chat.query.filter_by(channel_id=channel_id)\
.order_by(Chat.created_at.desc()).first()
if not last_message:
return False
state = cls(
user_id=user_id,
message_id=last_message.id,
channel_id=channel_id,
interaction_type=InteractionType.READ,
data={'last_read_message_id': last_message.id}
)
db.session.add(state)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
return False
Real-Time Updates
I’ve enhanced the WebSocket system to handle unread states:
@blueprint.route("/chat/create", methods=["POST"])
@login_required
def create_chat():
# ... existing chat creation code ...
# Emit two events:
# 1. To users in the channel to refresh their messages
current_app.socketio.emit("chat_changed", {
"channel": channel_name,
"message_id": chat.id,
"author_id": current_user.id,
"type": "refresh"
}, to=channel_name)
# 2. To all users to update unread badges
current_app.socketio.emit("message_created", {
"channel": channel_name,
"message_id": chat.id,
"author_id": current_user.id
}) # Broadcast to everyone
Flexible State Storage with JSON
The heart of thestate management system is the data
JSON field in the ChatMessageState
model. This field acts as a flexible container that adapts to different interaction types:
# Example states for different interaction types
READ_STATE = {
'last_read_message_id': 123, # Track last read message in channel
}
REACTION_STATE = {
'emoji': '👍',
'reaction_time': '2025-02-18T14:30:00Z'
}
BOOKMARK_STATE = {
'folder': 'Project Ideas',
'note': 'Discuss in next meeting'
}
EDIT_STATE = {
'previous_content': 'Original message',
'edit_timestamp': '2025-02-18T14:30:00Z'
}
PIN_STATE = {
'pin_position': 1,
'pin_context': 'Team announcement'
}
This approach offers several advantages:
- Schema Flexibility: New interaction features can be added without database migrations
- Contextual Data: Each interaction type can store its own specific metadata
- Query Efficiency: All states remain in one table while supporting complex data
- Version Control: Easy tracking of changes and history
- Feature Testing: New interaction types can be tested without schema changes
For example, when implementing read states, we store the last read message ID:
@classmethod
def mark_channel_read(cls, user_id, channel_id):
last_message = Chat.query.filter_by(channel_id=channel_id)\
.order_by(Chat.created_at.desc()).first()
if last_message:
state = cls(
user_id=user_id,
message_id=last_message.id,
channel_id=channel_id,
interaction_type=InteractionType.READ,
data={
'last_read_message_id': last_message.id,
'marked_read_at': datetime.utcnow().isoformat()
}
)
db.session.add(state)
When we implement reactions or bookmarks in the future, we’ll use the same table structure but with different data content. This design decision significantly reduces the complexity of adding new features while maintaining a clean database schema.
The JSON field essentially future-proofs our chat system, allowing it to evolve without the overhead of constant schema updates. Whether we’re adding emoji reactions, bookmark folders, or edit histories, the structure adapts to our needs.
Enhanced UI with Alpine.js
The channel list now includes reactive unread badges with smooth transitions:
<div class="channel" :class="{ 'active': currentChannel === channel.name }" x-data="{ id: channel.id, name: channel.name }">
<div class="channel-content" @click="switchChannel(name, id)">
<div class="channel-name">
<span class="channel-prefix">#</span> {{ channel.name }}
<span class="unread-badge" x-show="getUnreadCount(id) > 0" x-text="getUnreadCount(id)"></span>
</div>
</div>
The CSS remains the same, but the functionality is now handled through Alpine.js directives:
getUnreadCount(channelId) {
return this.channelStates[channelId]?.unreadCount || 0;
},
updateChannelUnreadCount(channelId, increment = true) {
if (!this.channelStates[channelId]) {
this.channelStates[channelId] = { unreadCount: 0 };
}
this.channelStates[channelId].unreadCount += increment ? 1 : -1;
this.persistUnreadCounts();
}
}
Future-Ready Features
The new architecture enables several upcoming features:
- Reactions: Store emoji reactions in the
data
JSON field - Bookmarks: Track saved messages for quick access
- Thread Awareness: Separate read states for main messages and threads
- Edit History: Track message modifications over time
- Soft Delete: Maintain message history while hiding content
Implementation Benefits
This restructuring has already provided several advantages:
- Cleaner Database: One table instead of multiple for different states
- Better Performance: Fewer joins needed for state queries
- Flexible Storage: JSON field adapts to different interaction needs
- Future-Proof: Easy addition of new interaction types
What’s Next
With this foundation in place, I’m planning to implement:
- Thread-specific unread indicators
- Reaction summaries
- Message bookmarking
- Enhanced notification preferences
- Read receipts
(Nah just kidding, I hope we don’t implement any of these. This is a lightweight Slack alternative after all.)
Conclusion
By evolving from the basic chat system to include smart state management and unread indicators, we’ve significantly improved the user experience while laying groundwork for future enhancements. The unified state model provides flexibility and efficiency, while the enhanced UI makes channel activity immediately visible to users.
The key was thinking ahead about future features while implementing current needs, resulting in a scalable architecture that can grow with our requirements.
Come and join our team by checking out the repo at github.com/sparqone/sparq.