Real-time Chat Application
A comprehensive real-time chat application demonstrating advanced Pinia patterns with WebSocket integration, message management, user presence, and real-time notifications.
Features
- 💬 Real-time messaging with WebSocket
- 👥 User presence and typing indicators
- 🏠 Multiple chat rooms/channels
- 📁 File sharing and media messages
- 🔍 Message search and history
- 🔔 Push notifications
- 😀 Emoji reactions and mentions
- 🌙 Online/offline status handling
- 📱 Responsive design
- 🔐 Message encryption (optional)
Type Definitions
typescript
// types/chat.ts
export interface User {
id: string
username: string
displayName: string
avatar?: string
status: 'online' | 'away' | 'busy' | 'offline'
lastSeen: Date
}
export interface Message {
id: string
content: string
type: 'text' | 'image' | 'file' | 'system'
senderId: string
roomId: string
timestamp: Date
edited?: boolean
editedAt?: Date
replyTo?: string
reactions: Record<string, string[]> // emoji -> user IDs
mentions: string[] // user IDs
attachments?: Attachment[]
}
export interface Attachment {
id: string
name: string
size: number
type: string
url: string
thumbnail?: string
}
export interface Room {
id: string
name: string
description?: string
type: 'public' | 'private' | 'direct'
members: string[] // user IDs
admins: string[] // user IDs
createdAt: Date
lastActivity: Date
unreadCount?: number
lastMessage?: Message
}
export interface TypingIndicator {
userId: string
roomId: string
timestamp: Date
}
export interface ChatState {
currentUser: User | null
rooms: Room[]
messages: Record<string, Message[]> // roomId -> messages
users: Record<string, User> // userId -> user
typingIndicators: TypingIndicator[]
onlineUsers: Set<string>
currentRoomId: string | null
isConnected: boolean
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'
}
WebSocket Service
typescript
// services/websocket.ts
export class WebSocketService {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 1000
private heartbeatInterval: number | null = null
private messageQueue: any[] = []
private eventHandlers = new Map<string, Function[]>()
constructor(private url: string, private token: string) {}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`${this.url}?token=${this.token}`)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
this.startHeartbeat()
this.flushMessageQueue()
this.emit('connected')
resolve()
}
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleMessage(data)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
this.ws.onclose = (event) => {
console.log('WebSocket disconnected:', event.code, event.reason)
this.stopHeartbeat()
this.emit('disconnected', { code: event.code, reason: event.reason })
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
this.emit('error', error)
reject(error)
}
} catch (error) {
reject(error)
}
})
}
disconnect() {
if (this.ws) {
this.ws.close(1000, 'Client disconnect')
this.ws = null
}
this.stopHeartbeat()
}
send(type: string, data: any) {
const message = { type, data, timestamp: Date.now() }
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
// Queue message for when connection is restored
this.messageQueue.push(message)
}
}
on(event: string, handler: Function) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, [])
}
this.eventHandlers.get(event)!.push(handler)
}
off(event: string, handler: Function) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
const index = handlers.indexOf(handler)
if (index !== -1) {
handlers.splice(index, 1)
}
}
}
private emit(event: string, data?: any) {
const handlers = this.eventHandlers.get(event)
if (handlers) {
handlers.forEach(handler => handler(data))
}
}
private handleMessage(message: any) {
this.emit(message.type, message.data)
}
private startHeartbeat() {
this.heartbeatInterval = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send('ping', {})
}
}, 30000) // 30 seconds
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
}
private scheduleReconnect() {
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
setTimeout(() => {
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
this.connect().catch(() => {
// Reconnection failed, will try again if attempts remain
})
}, delay)
}
private flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
// Connection lost again, put message back
this.messageQueue.unshift(message)
break
}
}
}
}
Chat Store Implementation
typescript
// stores/chat.ts
import { defineStore } from 'pinia'
import { WebSocketService } from '@/services/websocket'
import { useAuthStore } from './auth'
import { useNotificationStore } from './notifications'
export const useChatStore = defineStore('chat', () => {
const authStore = useAuthStore()
const notificationStore = useNotificationStore()
// State
const currentUser = ref<User | null>(null)
const rooms = ref<Room[]>([])
const messages = ref<Record<string, Message[]>>({})
const users = ref<Record<string, User>>({})
const typingIndicators = ref<TypingIndicator[]>([])
const onlineUsers = ref<Set<string>>(new Set())
const currentRoomId = ref<string | null>(null)
const isConnected = ref(false)
const connectionStatus = ref<ChatState['connectionStatus']>('disconnected')
const searchQuery = ref('')
const isLoading = ref(false)
const error = ref<string | null>(null)
// WebSocket service
let wsService: WebSocketService | null = null
// Getters
const currentRoom = computed(() => {
return currentRoomId.value ? rooms.value.find(r => r.id === currentRoomId.value) : null
})
const currentMessages = computed(() => {
return currentRoomId.value ? messages.value[currentRoomId.value] || [] : []
})
const filteredMessages = computed(() => {
if (!searchQuery.value) return currentMessages.value
const query = searchQuery.value.toLowerCase()
return currentMessages.value.filter(message =>
message.content.toLowerCase().includes(query) ||
users.value[message.senderId]?.displayName.toLowerCase().includes(query)
)
})
const roomsWithUnread = computed(() => {
return rooms.value.map(room => ({
...room,
unreadCount: getUnreadCount(room.id)
}))
})
const currentTypingUsers = computed(() => {
if (!currentRoomId.value) return []
const now = Date.now()
return typingIndicators.value
.filter(indicator =>
indicator.roomId === currentRoomId.value &&
indicator.userId !== currentUser.value?.id &&
now - indicator.timestamp.getTime() < 3000 // 3 seconds timeout
)
.map(indicator => users.value[indicator.userId])
.filter(Boolean)
})
const totalUnreadCount = computed(() => {
return rooms.value.reduce((total, room) => total + getUnreadCount(room.id), 0)
})
// Actions
async function initialize() {
if (!authStore.token) {
throw new Error('Authentication required')
}
connectionStatus.value = 'connecting'
try {
// Initialize WebSocket connection
wsService = new WebSocketService(
import.meta.env.VITE_WS_URL || 'ws://localhost:3001',
authStore.token
)
setupWebSocketHandlers()
await wsService.connect()
// Fetch initial data
await Promise.all([
fetchRooms(),
fetchUsers()
])
connectionStatus.value = 'connected'
isConnected.value = true
} catch (error) {
connectionStatus.value = 'error'
throw error
}
}
function setupWebSocketHandlers() {
if (!wsService) return
wsService.on('connected', () => {
isConnected.value = true
connectionStatus.value = 'connected'
error.value = null
})
wsService.on('disconnected', () => {
isConnected.value = false
connectionStatus.value = 'disconnected'
onlineUsers.value.clear()
})
wsService.on('error', (err) => {
error.value = 'Connection error'
connectionStatus.value = 'error'
})
wsService.on('message', handleNewMessage)
wsService.on('message_updated', handleMessageUpdate)
wsService.on('message_deleted', handleMessageDelete)
wsService.on('user_joined', handleUserJoined)
wsService.on('user_left', handleUserLeft)
wsService.on('user_typing', handleUserTyping)
wsService.on('user_status', handleUserStatus)
wsService.on('room_updated', handleRoomUpdate)
}
async function fetchRooms() {
try {
const response = await fetch('/api/chat/rooms', {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch rooms')
}
const data = await response.json()
rooms.value = data.map(room => ({
...room,
createdAt: new Date(room.createdAt),
lastActivity: new Date(room.lastActivity)
}))
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function fetchUsers() {
try {
const response = await fetch('/api/chat/users', {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch users')
}
const data = await response.json()
const usersMap = {}
data.forEach(user => {
usersMap[user.id] = {
...user,
lastSeen: new Date(user.lastSeen)
}
})
users.value = usersMap
// Set current user
currentUser.value = users.value[authStore.user?.id] || null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function fetchMessages(roomId: string, limit = 50, before?: string) {
try {
const params = new URLSearchParams({
limit: limit.toString(),
...(before && { before })
})
const response = await fetch(`/api/chat/rooms/${roomId}/messages?${params}`, {
headers: {
'Authorization': `Bearer ${authStore.token}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch messages')
}
const data = await response.json()
const roomMessages = data.map(msg => ({
...msg,
timestamp: new Date(msg.timestamp),
editedAt: msg.editedAt ? new Date(msg.editedAt) : undefined
}))
if (before) {
// Prepend older messages
messages.value[roomId] = [...roomMessages, ...(messages.value[roomId] || [])]
} else {
// Replace with new messages
messages.value[roomId] = roomMessages
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
throw err
}
}
async function joinRoom(roomId: string) {
currentRoomId.value = roomId
// Fetch messages if not already loaded
if (!messages.value[roomId]) {
await fetchMessages(roomId)
}
// Mark room as read
markRoomAsRead(roomId)
// Notify server
wsService?.send('join_room', { roomId })
}
function leaveRoom() {
if (currentRoomId.value) {
wsService?.send('leave_room', { roomId: currentRoomId.value })
currentRoomId.value = null
}
}
async function sendMessage(content: string, type: Message['type'] = 'text', replyTo?: string) {
if (!currentRoomId.value || !currentUser.value) return
const tempId = `temp-${Date.now()}`
const optimisticMessage: Message = {
id: tempId,
content,
type,
senderId: currentUser.value.id,
roomId: currentRoomId.value,
timestamp: new Date(),
replyTo,
reactions: {},
mentions: extractMentions(content)
}
// Optimistic update
if (!messages.value[currentRoomId.value]) {
messages.value[currentRoomId.value] = []
}
messages.value[currentRoomId.value].push(optimisticMessage)
try {
wsService?.send('send_message', {
content,
type,
roomId: currentRoomId.value,
replyTo,
tempId
})
} catch (err) {
// Remove optimistic message on error
const roomMessages = messages.value[currentRoomId.value]
const index = roomMessages.findIndex(m => m.id === tempId)
if (index !== -1) {
roomMessages.splice(index, 1)
}
throw err
}
}
async function editMessage(messageId: string, newContent: string) {
if (!currentRoomId.value) return
const roomMessages = messages.value[currentRoomId.value]
const messageIndex = roomMessages.findIndex(m => m.id === messageId)
if (messageIndex === -1) return
const originalMessage = { ...roomMessages[messageIndex] }
// Optimistic update
roomMessages[messageIndex] = {
...originalMessage,
content: newContent,
edited: true,
editedAt: new Date()
}
try {
wsService?.send('edit_message', {
messageId,
content: newContent,
roomId: currentRoomId.value
})
} catch (err) {
// Rollback on error
roomMessages[messageIndex] = originalMessage
throw err
}
}
async function deleteMessage(messageId: string) {
if (!currentRoomId.value) return
const roomMessages = messages.value[currentRoomId.value]
const messageIndex = roomMessages.findIndex(m => m.id === messageId)
if (messageIndex === -1) return
const deletedMessage = roomMessages[messageIndex]
// Optimistic update
roomMessages.splice(messageIndex, 1)
try {
wsService?.send('delete_message', {
messageId,
roomId: currentRoomId.value
})
} catch (err) {
// Rollback on error
roomMessages.splice(messageIndex, 0, deletedMessage)
throw err
}
}
function addReaction(messageId: string, emoji: string) {
if (!currentRoomId.value || !currentUser.value) return
const roomMessages = messages.value[currentRoomId.value]
const message = roomMessages.find(m => m.id === messageId)
if (!message) return
if (!message.reactions[emoji]) {
message.reactions[emoji] = []
}
const userId = currentUser.value.id
if (!message.reactions[emoji].includes(userId)) {
message.reactions[emoji].push(userId)
wsService?.send('add_reaction', {
messageId,
emoji,
roomId: currentRoomId.value
})
}
}
function removeReaction(messageId: string, emoji: string) {
if (!currentRoomId.value || !currentUser.value) return
const roomMessages = messages.value[currentRoomId.value]
const message = roomMessages.find(m => m.id === messageId)
if (!message || !message.reactions[emoji]) return
const userId = currentUser.value.id
const index = message.reactions[emoji].indexOf(userId)
if (index !== -1) {
message.reactions[emoji].splice(index, 1)
if (message.reactions[emoji].length === 0) {
delete message.reactions[emoji]
}
wsService?.send('remove_reaction', {
messageId,
emoji,
roomId: currentRoomId.value
})
}
}
function startTyping() {
if (!currentRoomId.value) return
wsService?.send('typing_start', {
roomId: currentRoomId.value
})
}
function stopTyping() {
if (!currentRoomId.value) return
wsService?.send('typing_stop', {
roomId: currentRoomId.value
})
}
function markRoomAsRead(roomId: string) {
const room = rooms.value.find(r => r.id === roomId)
if (room) {
room.unreadCount = 0
}
wsService?.send('mark_read', { roomId })
}
function getUnreadCount(roomId: string): number {
const room = rooms.value.find(r => r.id === roomId)
return room?.unreadCount || 0
}
function extractMentions(content: string): string[] {
const mentionRegex = /@(\w+)/g
const mentions = []
let match
while ((match = mentionRegex.exec(content)) !== null) {
const username = match[1]
const user = Object.values(users.value).find(u => u.username === username)
if (user) {
mentions.push(user.id)
}
}
return mentions
}
// WebSocket event handlers
function handleNewMessage(data: any) {
const message: Message = {
...data,
timestamp: new Date(data.timestamp)
}
// Replace temporary message if it exists
if (data.tempId) {
const roomMessages = messages.value[message.roomId]
if (roomMessages) {
const tempIndex = roomMessages.findIndex(m => m.id === data.tempId)
if (tempIndex !== -1) {
roomMessages[tempIndex] = message
return
}
}
}
// Add new message
if (!messages.value[message.roomId]) {
messages.value[message.roomId] = []
}
messages.value[message.roomId].push(message)
// Update room's last activity
const room = rooms.value.find(r => r.id === message.roomId)
if (room) {
room.lastActivity = message.timestamp
room.lastMessage = message
// Increment unread count if not current room
if (message.roomId !== currentRoomId.value && message.senderId !== currentUser.value?.id) {
room.unreadCount = (room.unreadCount || 0) + 1
}
}
// Show notification if not current room
if (message.roomId !== currentRoomId.value && message.senderId !== currentUser.value?.id) {
const sender = users.value[message.senderId]
notificationStore.addNotification({
title: `${sender?.displayName || 'Someone'} in ${room?.name || 'Chat'}`,
message: message.content,
type: 'info',
action: () => joinRoom(message.roomId)
})
}
}
function handleMessageUpdate(data: any) {
const roomMessages = messages.value[data.roomId]
if (roomMessages) {
const messageIndex = roomMessages.findIndex(m => m.id === data.id)
if (messageIndex !== -1) {
roomMessages[messageIndex] = {
...roomMessages[messageIndex],
...data,
timestamp: new Date(data.timestamp),
editedAt: data.editedAt ? new Date(data.editedAt) : undefined
}
}
}
}
function handleMessageDelete(data: any) {
const roomMessages = messages.value[data.roomId]
if (roomMessages) {
const messageIndex = roomMessages.findIndex(m => m.id === data.messageId)
if (messageIndex !== -1) {
roomMessages.splice(messageIndex, 1)
}
}
}
function handleUserJoined(data: any) {
onlineUsers.value.add(data.userId)
if (users.value[data.userId]) {
users.value[data.userId].status = 'online'
}
}
function handleUserLeft(data: any) {
onlineUsers.value.delete(data.userId)
if (users.value[data.userId]) {
users.value[data.userId].status = 'offline'
users.value[data.userId].lastSeen = new Date()
}
}
function handleUserTyping(data: any) {
const existingIndex = typingIndicators.value.findIndex(
indicator => indicator.userId === data.userId && indicator.roomId === data.roomId
)
const typingIndicator: TypingIndicator = {
userId: data.userId,
roomId: data.roomId,
timestamp: new Date()
}
if (existingIndex !== -1) {
typingIndicators.value[existingIndex] = typingIndicator
} else {
typingIndicators.value.push(typingIndicator)
}
// Remove typing indicator after timeout
setTimeout(() => {
const index = typingIndicators.value.findIndex(
indicator => indicator.userId === data.userId && indicator.roomId === data.roomId
)
if (index !== -1) {
typingIndicators.value.splice(index, 1)
}
}, 3000)
}
function handleUserStatus(data: any) {
if (users.value[data.userId]) {
users.value[data.userId].status = data.status
if (data.status === 'offline') {
users.value[data.userId].lastSeen = new Date()
onlineUsers.value.delete(data.userId)
} else {
onlineUsers.value.add(data.userId)
}
}
}
function handleRoomUpdate(data: any) {
const roomIndex = rooms.value.findIndex(r => r.id === data.id)
if (roomIndex !== -1) {
rooms.value[roomIndex] = {
...rooms.value[roomIndex],
...data,
createdAt: new Date(data.createdAt),
lastActivity: new Date(data.lastActivity)
}
}
}
function disconnect() {
wsService?.disconnect()
wsService = null
isConnected.value = false
connectionStatus.value = 'disconnected'
onlineUsers.value.clear()
typingIndicators.value = []
}
// Cleanup on unmount
onUnmounted(() => {
disconnect()
})
return {
// State
currentUser: readonly(currentUser),
rooms: readonly(rooms),
messages: readonly(messages),
users: readonly(users),
typingIndicators: readonly(typingIndicators),
onlineUsers: readonly(onlineUsers),
currentRoomId: readonly(currentRoomId),
isConnected: readonly(isConnected),
connectionStatus: readonly(connectionStatus),
searchQuery,
isLoading: readonly(isLoading),
error: readonly(error),
// Getters
currentRoom,
currentMessages,
filteredMessages,
roomsWithUnread,
currentTypingUsers,
totalUnreadCount,
// Actions
initialize,
joinRoom,
leaveRoom,
sendMessage,
editMessage,
deleteMessage,
addReaction,
removeReaction,
startTyping,
stopTyping,
markRoomAsRead,
fetchMessages,
disconnect
}
})
Component Usage
Chat Room Component
vue
<!-- components/ChatRoom.vue -->
<template>
<div class="chat-room">
<!-- Header -->
<div class="chat-header">
<div class="room-info">
<h3>{{ chatStore.currentRoom?.name }}</h3>
<span class="member-count">
{{ chatStore.currentRoom?.members.length }} members
</span>
</div>
<div class="room-actions">
<button @click="showSearch = !showSearch">
🔍 Search
</button>
<button @click="showMembers = !showMembers">
👥 Members
</button>
</div>
</div>
<!-- Search -->
<div v-if="showSearch" class="search-bar">
<input
v-model="chatStore.searchQuery"
placeholder="Search messages..."
class="search-input"
/>
</div>
<!-- Messages -->
<div
ref="messagesContainer"
class="messages-container"
@scroll="handleScroll"
>
<div
v-for="message in chatStore.filteredMessages"
:key="message.id"
class="message"
:class="{
'own-message': message.senderId === chatStore.currentUser?.id,
'system-message': message.type === 'system'
}"
>
<MessageItem
:message="message"
:user="chatStore.users[message.senderId]"
@edit="handleEditMessage"
@delete="handleDeleteMessage"
@react="handleAddReaction"
@reply="handleReplyToMessage"
/>
</div>
<!-- Typing indicators -->
<div v-if="chatStore.currentTypingUsers.length > 0" class="typing-indicators">
<div class="typing-indicator">
{{ formatTypingUsers(chatStore.currentTypingUsers) }}
{{ chatStore.currentTypingUsers.length === 1 ? 'is' : 'are' }} typing...
</div>
</div>
</div>
<!-- Message Input -->
<div class="message-input-container">
<div v-if="replyingTo" class="reply-preview">
<span>Replying to {{ chatStore.users[replyingTo.senderId]?.displayName }}</span>
<button @click="replyingTo = null">✕</button>
</div>
<div class="message-input">
<input
v-model="messageText"
placeholder="Type a message..."
@keydown="handleKeyDown"
@input="handleTyping"
@blur="handleStopTyping"
/>
<div class="input-actions">
<button @click="showEmojiPicker = !showEmojiPicker">
😀
</button>
<button @click="handleFileUpload">
📎
</button>
<button
@click="handleSendMessage"
:disabled="!messageText.trim()"
>
Send
</button>
</div>
</div>
<!-- Emoji Picker -->
<EmojiPicker
v-if="showEmojiPicker"
@select="handleEmojiSelect"
@close="showEmojiPicker = false"
/>
</div>
<!-- Members Sidebar -->
<MembersList
v-if="showMembers"
:members="roomMembers"
@close="showMembers = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import MessageItem from './MessageItem.vue'
import EmojiPicker from './EmojiPicker.vue'
import MembersList from './MembersList.vue'
const chatStore = useChatStore()
const messageText = ref('')
const replyingTo = ref<Message | null>(null)
const showSearch = ref(false)
const showMembers = ref(false)
const showEmojiPicker = ref(false)
const messagesContainer = ref<HTMLElement>()
const typingTimeout = ref<number | null>(null)
const isTyping = ref(false)
const roomMembers = computed(() => {
if (!chatStore.currentRoom) return []
return chatStore.currentRoom.members.map(id => chatStore.users[id]).filter(Boolean)
})
function handleSendMessage() {
if (!messageText.value.trim()) return
chatStore.sendMessage(
messageText.value,
'text',
replyingTo.value?.id
)
messageText.value = ''
replyingTo.value = null
handleStopTyping()
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSendMessage()
}
}
function handleTyping() {
if (!isTyping.value) {
isTyping.value = true
chatStore.startTyping()
}
// Reset typing timeout
if (typingTimeout.value) {
clearTimeout(typingTimeout.value)
}
typingTimeout.value = setTimeout(() => {
handleStopTyping()
}, 1000)
}
function handleStopTyping() {
if (isTyping.value) {
isTyping.value = false
chatStore.stopTyping()
}
if (typingTimeout.value) {
clearTimeout(typingTimeout.value)
typingTimeout.value = null
}
}
function handleEditMessage(messageId: string, newContent: string) {
chatStore.editMessage(messageId, newContent)
}
function handleDeleteMessage(messageId: string) {
if (confirm('Delete this message?')) {
chatStore.deleteMessage(messageId)
}
}
function handleAddReaction(messageId: string, emoji: string) {
chatStore.addReaction(messageId, emoji)
}
function handleReplyToMessage(message: Message) {
replyingTo.value = message
}
function handleEmojiSelect(emoji: string) {
messageText.value += emoji
showEmojiPicker.value = false
}
function handleFileUpload() {
// Implement file upload logic
console.log('File upload not implemented')
}
function handleScroll() {
const container = messagesContainer.value
if (!container) return
// Load more messages when scrolled to top
if (container.scrollTop === 0 && chatStore.currentMessages.length > 0) {
const oldestMessage = chatStore.currentMessages[0]
chatStore.fetchMessages(chatStore.currentRoomId!, 50, oldestMessage.id)
}
}
function formatTypingUsers(users: User[]): string {
if (users.length === 1) {
return users[0].displayName
} else if (users.length === 2) {
return `${users[0].displayName} and ${users[1].displayName}`
} else {
return `${users[0].displayName} and ${users.length - 1} others`
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// Auto-scroll to bottom when new messages arrive
watch(() => chatStore.currentMessages.length, scrollToBottom)
onMounted(() => {
scrollToBottom()
})
onUnmounted(() => {
handleStopTyping()
})
</script>
Testing
typescript
// tests/stores/chat.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChatStore } from '@/stores/chat'
import { WebSocketService } from '@/services/websocket'
// Mock WebSocket
vi.mock('@/services/websocket')
describe('Chat Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('should handle new messages correctly', () => {
const store = useChatStore()
const message = {
id: '1',
content: 'Hello world',
senderId: 'user1',
roomId: 'room1',
timestamp: new Date().toISOString()
}
store.handleNewMessage(message)
expect(store.messages['room1']).toHaveLength(1)
expect(store.messages['room1'][0].content).toBe('Hello world')
})
it('should handle optimistic message updates', async () => {
const store = useChatStore()
store.currentRoomId = 'room1'
store.currentUser = { id: 'user1', username: 'test' }
// Mock WebSocket service
const mockWs = {
send: vi.fn()
}
store.wsService = mockWs
await store.sendMessage('Test message')
expect(store.messages['room1']).toHaveLength(1)
expect(store.messages['room1'][0].content).toBe('Test message')
expect(store.messages['room1'][0].id).toMatch(/^temp-/)
expect(mockWs.send).toHaveBeenCalledWith('send_message', expect.any(Object))
})
it('should extract mentions correctly', () => {
const store = useChatStore()
store.users = {
'user1': { id: 'user1', username: 'john' },
'user2': { id: 'user2', username: 'jane' }
}
const mentions = store.extractMentions('Hello @john and @jane!')
expect(mentions).toEqual(['user1', 'user2'])
})
it('should handle typing indicators', () => {
const store = useChatStore()
store.handleUserTyping({
userId: 'user1',
roomId: 'room1'
})
expect(store.typingIndicators).toHaveLength(1)
expect(store.typingIndicators[0].userId).toBe('user1')
})
})
Key Features
1. Real-time Communication
- WebSocket connection with auto-reconnection
- Real-time message delivery
- Typing indicators and user presence
- Connection status monitoring
2. Message Management
- Optimistic updates for instant feedback
- Message editing and deletion
- Emoji reactions and mentions
- File attachments support
3. User Experience
- Unread message counts
- Push notifications
- Message search and history
- Responsive design
4. Performance
- Message pagination and lazy loading
- Efficient state management
- Memory optimization for large chat histories
5. Reliability
- Offline message queuing
- Error handling and recovery
- Message delivery confirmation
This real-time chat application demonstrates how to build complex, interactive applications with Pinia while handling real-time data, WebSocket connections, and providing a smooth user experience.