Skip to content
Open
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
36 changes: 18 additions & 18 deletions app/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use client";
"use client"

import React, {
useEffect,
Expand Down Expand Up @@ -46,24 +46,24 @@ import { useWebSocketSend, useWebSocketMessage } from "@/lib/websocket/hooks";
import { WebSocketMessage } from "@/types/websocket";

type ChatPreview = {
id: string;
name: string;
address: string;
lastMessage: string;
lastSeen: string;
unreadCount: number;
status: PresenceStatus;
};
id: string
name: string
address: string
lastMessage: string
lastSeen: string
unreadCount: number
status: PresenceStatus
}

type ChatMessage = {
id: string;
author: "me" | "them";
text: string;
time: string;
delivered: boolean;
read: boolean;
status?: "sending" | "sent" | "delivered" | "read";
};
id: string
author: "me" | "them"
text: string
time: string
delivered: boolean
read: boolean
status?: "sending" | "sent" | "delivered" | "read"
}

interface DBRoom {
id: string;
Expand Down Expand Up @@ -365,4 +365,4 @@ export default function ChatPage() {
<Footer />
</div>
);
}
}
49 changes: 49 additions & 0 deletions lib/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
interface RateLimitConfig {
maxMessages: number
windowMs: number
}

interface MessageRecord {
timestamps: number[]
}

const DEFAULT_CONFIG: RateLimitConfig = {
maxMessages: 5,
windowMs: 10000, // 10 seconds
}

class RateLimiter {
private records = new Map<string, MessageRecord>()
private config: RateLimitConfig

constructor(config: Partial<RateLimitConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }
}

check(walletAddress: string): { allowed: boolean; remainingMs?: number } {
const now = Date.now()
const record = this.records.get(walletAddress) || { timestamps: [] }

// Remove timestamps outside the window
record.timestamps = record.timestamps.filter(
(ts) => now - ts < this.config.windowMs
)

if (record.timestamps.length >= this.config.maxMessages) {
const oldestTimestamp = record.timestamps[0]
const remainingMs = this.config.windowMs - (now - oldestTimestamp)
return { allowed: false, remainingMs }
}

record.timestamps.push(now)
this.records.set(walletAddress, record)

return { allowed: true }
}

reset(walletAddress: string): void {
this.records.delete(walletAddress)
}
}

export const rateLimiter = new RateLimiter()
9 changes: 7 additions & 2 deletions lib/websocket/chat-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ export function useRealtimeChat(roomId: string, userId?: string) {

setMessages((prev) => [...prev, optimisticMessage])

// Send via WebSocket
sendMessage(roomId, content)
// Send via WebSocket with rate limit check
const result = sendMessage(roomId, content)
if (!result.success) {
// Remove optimistic message on failure
setMessages((prev) => prev.filter((m) => m.id !== optimisticMessage.id))
toast.error(result.error || "Failed to send message")
}
},
[roomId, userId, sendMessage],
)
Expand Down
19 changes: 18 additions & 1 deletion lib/websocket/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
WebSocketClientEventType,
ConnectionState,
} from "@/types/websocket"
import { rateLimiter } from "@/lib/rate-limiter"

const RECONNECT_ATTEMPTS = 5
const INITIAL_RECONNECT_DELAY = 1000 // 1 second
Expand All @@ -20,6 +21,7 @@ export class WebSocketClient {
private reconnectDelay = INITIAL_RECONNECT_DELAY
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private clientId: string | null = null
private walletAddress: string | null = null

constructor(url: string) {
this.url = url
Expand Down Expand Up @@ -189,6 +191,7 @@ export class WebSocketClient {

// Convenience methods for common messages
authenticate(userId: string, walletAddress: string, displayName: string, avatarUrl?: string) {
this.walletAddress = walletAddress
this.send({
type: "auth",
payload: {
Expand Down Expand Up @@ -217,12 +220,26 @@ export class WebSocketClient {
})
}

sendMessage(roomId: string, content: string) {
sendMessage(roomId: string, content: string): { success: boolean; error?: string } {
if (!this.walletAddress) {
return { success: false, error: "Wallet not connected" }
}

const rateLimitCheck = rateLimiter.check(this.walletAddress)
if (!rateLimitCheck.allowed) {
const seconds = Math.ceil((rateLimitCheck.remainingMs || 0) / 1000)
return {
success: false,
error: `You are sending messages too quickly. Please wait ${seconds} second${seconds !== 1 ? 's' : ''}.`
}
}

this.send({
type: "send_message",
payload: { roomId, content },
timestamp: Date.now(),
})
return { success: true }
}

notifyTyping(roomId: string) {
Expand Down
2 changes: 1 addition & 1 deletion lib/websocket/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function useWebSocketSend() {
client.current.leaveRoom(roomId)
}, []),
sendMessage: useCallback((roomId: string, content: string) => {
client.current.sendMessage(roomId, content)
return client.current.sendMessage(roomId, content)
}, []),
notifyTyping: useCallback((roomId: string) => {
client.current.notifyTyping(roomId)
Expand Down
Loading