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
2 changes: 1 addition & 1 deletion Dockerfile.chat-service
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,12 @@ public Message sendMessage(String conversationId, String senderId, String body)
}

private void broadcast(Message msg) {
var resp = new com.blink.chatservice.websocket.dto.RealtimeMessageResponse(msg.getId(), msg.getConversationId(), msg.getSenderId(), msg.getRecipientId(), msg.getBody(), msg.getCreatedAt());
messagingTemplate.convertAndSend("/topic/conversations/" + msg.getConversationId(), resp);
if (msg.getRecipientId() != null) messagingTemplate.convertAndSendToUser(msg.getRecipientId(), "/queue/messages", resp);
messagingTemplate.convertAndSendToUser(msg.getSenderId(), "/queue/messages", resp);
java.util.concurrent.CompletableFuture.runAsync(() -> {
var resp = new com.blink.chatservice.websocket.dto.RealtimeMessageResponse(msg.getId(), msg.getConversationId(), msg.getSenderId(), msg.getRecipientId(), msg.getBody(), msg.getCreatedAt());
messagingTemplate.convertAndSend("/topic/conversations/" + msg.getConversationId(), resp);
if (msg.getRecipientId() != null) messagingTemplate.convertAndSendToUser(msg.getRecipientId(), "/queue/messages", resp);
messagingTemplate.convertAndSendToUser(msg.getSenderId(), "/queue/messages", resp);
});
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/swagger-ui/**",
"/swagger-ui.html",
"/swagger-ui/index.html",
"/actuator/health",
"/actuator/info",
// Allowing public access to ws handshake, socket security is handled separately.
"/ws/**"
).permitAll()
Expand Down
2 changes: 1 addition & 1 deletion chat-service/src/main/resources/application-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ spring:
pool:
max-active: ${REDIS_POOL_MAX_ACTIVE:16}
max-idle: ${REDIS_POOL_MAX_IDLE:8}
min-idle: ${REDIS_POOL_MIN_IDLE:2}
min-idle: ${REDIS_POOL_MIN_IDLE:4}
max-wait: ${REDIS_POOL_MAX_WAIT:2000ms}

mail:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ void sendMessage_shouldSaveMessageAndBroadcast() {
assertNotNull(result);
assertEquals("Hello", result.getBody());
verify(messageRepository).save(any(Message.class));
verify(messagingTemplate, atLeastOnce()).convertAndSend(anyString(), any(Object.class));
verify(messagingTemplate, timeout(1000).atLeastOnce()).convertAndSend(anyString(), any(Object.class));
}

@Test
Expand Down
100 changes: 52 additions & 48 deletions frontend-v2/src/components/chat/MessageList.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useInfiniteQuery, useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState, useLayoutEffect, useCallback } from 'react';
import { useEffect, useRef, useState, useLayoutEffect, useCallback, useMemo, memo } from 'react';
import { chatService, socketService, userService } from '../../services';
import { queryKeys } from '../../lib/queryClient';
import { useAuthStore, useChatStore } from '../../stores';
Expand Down Expand Up @@ -43,7 +43,7 @@ function getDateKey(dateString) {
}

// ─── Date separator component ────────────────────────────────────────
function DateSeparator({ label }) {
const DateSeparator = memo(function DateSeparator({ label }) {
return (
<div className="flex items-center justify-center my-4 select-none pointer-events-none">
<div className="flex items-center gap-3 w-full max-w-xs">
Expand All @@ -55,7 +55,7 @@ function DateSeparator({ label }) {
</div>
</div>
);
}
});

// ─── Main component ──────────────────────────────────────────────────
export function MessageList({ conversationId }) {
Expand Down Expand Up @@ -249,53 +249,57 @@ export function MessageList({ conversationId }) {
};

// Deduplicate history messages by ID
const allHistoryMessages = data?.pages.flatMap(parseMessages) || [];
const historyMessagesMap = new Map();
allHistoryMessages.forEach(rxMsg => {
const msg = {
...rxMsg,
createdAt: rxMsg.createdAt && !rxMsg.createdAt.endsWith('Z')
? `${rxMsg.createdAt}Z`
: rxMsg.createdAt
};
historyMessagesMap.set(msg.id, msg);
});
const historyMessages = Array.from(historyMessagesMap.values());
const historyMessages = useMemo(() => {
const allHistoryMessages = data?.pages.flatMap(parseMessages) || [];
const historyMessagesMap = new Map();
allHistoryMessages.forEach(rxMsg => {
const msg = {
...rxMsg,
createdAt: rxMsg.createdAt && !rxMsg.createdAt.endsWith('Z')
? `${rxMsg.createdAt}Z`
: rxMsg.createdAt
};
historyMessagesMap.set(msg.id, msg);
});
return Array.from(historyMessagesMap.values());
}, [data]);

const optimisticArray = Object.values(optimisticMessages).filter(
(msg) => msg.conversationId === conversationId
);
const sortedMessages = useMemo(() => {
const optimisticArray = Object.values(optimisticMessages).filter(
(msg) => msg.conversationId === conversationId
);

// COMPREHENSIVE DEDUPLICATION
const allMessagesMap = new Map();
historyMessages.forEach(msg => { allMessagesMap.set(msg.id, msg); });

const currentLive = liveMessages[conversationId] || [];
currentLive.forEach(msg => { allMessagesMap.set(msg.id, msg); });

optimisticArray.forEach(optMsg => {
const isDuplicate = Array.from(allMessagesMap.values()).some(realMsg => {
const contentMatch = realMsg.body?.trim() === optMsg.body?.trim();
const senderMatch = realMsg.senderId === optMsg.senderId ||
(optMsg.senderId === 'me' && realMsg.senderId === user?.id) ||
(realMsg.senderId === 'me' && optMsg.senderId === user?.id);
const timeDiff = Math.abs(new Date(realMsg.createdAt).getTime() - new Date(optMsg.createdAt).getTime());
const timeMatch = timeDiff < 300000;
return contentMatch && senderMatch && timeMatch;
// COMPREHENSIVE DEDUPLICATION
const allMessagesMap = new Map();
historyMessages.forEach(msg => { allMessagesMap.set(msg.id, msg); });

const currentLive = liveMessages[conversationId] || [];
currentLive.forEach(msg => { allMessagesMap.set(msg.id, msg); });

optimisticArray.forEach(optMsg => {
const isDuplicate = Array.from(allMessagesMap.values()).some(realMsg => {
const contentMatch = realMsg.body?.trim() === optMsg.body?.trim();
const senderMatch = realMsg.senderId === optMsg.senderId ||
(optMsg.senderId === 'me' && realMsg.senderId === user?.id) ||
(realMsg.senderId === 'me' && optMsg.senderId === user?.id);
const timeDiff = Math.abs(new Date(realMsg.createdAt).getTime() - new Date(optMsg.createdAt).getTime());
const timeMatch = timeDiff < 300000;
return contentMatch && senderMatch && timeMatch;
});
if (!isDuplicate) {
allMessagesMap.set(optMsg.id, optMsg);
}
});
if (!isDuplicate) {
allMessagesMap.set(optMsg.id, optMsg);
}
});

const sortedMessages = Array.from(allMessagesMap.values()).sort((a, b) => {
const isRealA = a.id && /^[0-9a-fA-F]{24}$/.test(a.id);
const isRealB = b.id && /^[0-9a-fA-F]{24}$/.test(b.id);
if (isRealA && isRealB) {
return a.id.localeCompare(b.id);
}
return new Date(a.createdAt || a.timestamp) - new Date(b.createdAt || b.timestamp);
});
return Array.from(allMessagesMap.values()).sort((a, b) => {
const isRealA = a.id && /^[0-9a-fA-F]{24}$/.test(a.id);
const isRealB = b.id && /^[0-9a-fA-F]{24}$/.test(b.id);
if (isRealA && isRealB) {
return a.id.localeCompare(b.id);
}
return new Date(a.createdAt || a.timestamp) - new Date(b.createdAt || b.timestamp);
});
}, [historyMessages, liveMessages, optimisticMessages, conversationId, user?.id]);

// ─── Scroll helpers ──────────────────────────────────────────────
const isInitialLoadRef = useRef(true);
Expand Down Expand Up @@ -588,7 +592,7 @@ function renderMessageWithLinks(text, isOwn) {
}

// ─── Message bubble ──────────────────────────────────────────────────
function MessageBubble({
const MessageBubble = memo(function MessageBubble({
message,
isOwn,
showAvatar,
Expand Down Expand Up @@ -766,4 +770,4 @@ function MessageBubble({
</div>
</div>
);
}
});
90 changes: 90 additions & 0 deletions verification/verify_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from playwright.sync_api import sync_playwright, Page, expect

def test_chat_interface(page: Page):
# Debug console
page.on("console", lambda msg: print(f"Browser console: {msg.text}"))
page.on("requestfailed", lambda request: print(f"Request failed: {request.url} - {request.failure}"))
page.on("requestfinished", lambda request: print(f"Request finished: {request.url} - {request.response().status}"))

# Mock user session
page.route("**/api/v1/auth/google/session", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"user": {"id": "user-1", "username": "Me", "avatarUrl": null}, "accessToken": "fake-token"}'
))

# Mock conversations
page.route("**/api/v1/chat/conversations", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='[{"id": "conv-1", "title": "Test Chat", "type": "DIRECT", "participants": [{"id": "user-1", "username": "Me"}, {"id": "user-2", "username": "OtherUser"}], "updatedAt": "2023-01-01T12:00:00Z"}]'
))

# Mock specific conversation
page.route("**/api/v1/chat/conv-1", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"id": "conv-1", "title": "Test Chat", "type": "DIRECT", "participants": [{"id": "user-1", "username": "Me"}, {"id": "user-2", "username": "OtherUser"}]}'
))

# Mock messages
page.route("**/api/v1/chat/conv-1/messages?*", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"content": [{"id": "msg-1", "body": "Hello world", "senderId": "user-2", "createdAt": "2023-01-01T12:00:00Z"}, {"id": "msg-2", "body": "Hi there!", "senderId": "user-1", "createdAt": "2023-01-01T12:01:00Z"}], "totalPages": 1, "totalElements": 2, "last": true}'
))

# Mock user profile (for OtherUser)
page.route("**/api/v1/users/user-2", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"id": "user-2", "username": "OtherUser", "avatarUrl": null}'
))

# Mock other failing endpoints
page.route("**/api/v1/users/online", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='[]'
))
page.route("**/api/v1/ai/conversation", lambda route: route.fulfill(status=404)) # Or 200 []

# Mock websocket
page.route("**/ws/**", lambda route: route.fulfill(status=101))

# 1. Arrange: Go to the app
page.goto("http://localhost:5173/")

# 2. Wait for session check and redirect/render
try:
page.wait_for_selector("text=Me", timeout=10000)
except:
print("Waiting for 'Me' timed out. Checking if redirected to login.")

# Click on the conversation "Test Chat"
# Need to wait for conversation list to load
try:
page.wait_for_selector("text=Test Chat", timeout=10000)
page.get_by_text("Test Chat").click()
except:
print("Failed to find 'Test Chat'.")

# Wait for messages to load
expect(page.get_by_text("Hello world")).to_be_visible()
expect(page.get_by_text("Hi there!")).to_be_visible()

# 3. Screenshot
page.screenshot(path="verification/chat_interface.png", full_page=True)

if __name__ == "__main__":
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
test_chat_interface(page)
print("Verification script ran successfully.")
except Exception as e:
print(f"Verification script failed: {e}")
page.screenshot(path="verification/error.png")
finally:
browser.close()
Loading