7 Commits
6 changed files with 506 additions and 471 deletions
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain; using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Modules.Conversations.Infrastructure.SignalR; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
+1 -1
View File
@@ -575,7 +575,7 @@ const translations = {
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.', clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
clearHistory: 'Clear history', clearHistory: 'Clear history',
clearHistoryConfirm: 'Clear history?', clearHistoryConfirm: 'Clear history?',
deleteChatConfirm: 'Delete this chat? This action cannot be undone.', deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.', deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
pinChat: 'Pin chat', pinChat: 'Pin chat',
unpinChat: 'Unpin chat', unpinChat: 'Unpin chat',
@@ -65,6 +65,8 @@ export default function ChatPage() {
const [activeTab, setActiveTab] = useState('chats'); const [activeTab, setActiveTab] = useState('chats');
const { t } = useLang(); const { t } = useLang();
const activeChat = useChatStore((state) => state.activeChat);
useEffect(() => { useEffect(() => {
groupCallOpenRef.current = groupCallOpen; groupCallOpenRef.current = groupCallOpen;
groupCallChatIdRef.current = groupCallChatId; groupCallChatIdRef.current = groupCallChatId;
@@ -335,6 +337,16 @@ export default function ChatPage() {
}; };
}, [user?.id]); }, [user?.id]);
// Join chat group when activeChat changes
useEffect(() => {
if (activeChat) {
const socket = getSocket();
if (socket) {
socket.emit('join_chat', activeChat);
}
}
}, [activeChat]);
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => { const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
setCallTarget(targetUser); setCallTarget(targetUser);
setCallType(type); setCallType(type);
@@ -371,8 +383,6 @@ export default function ChatPage() {
setGroupCallOpen(false); setGroupCallOpen(false);
}; };
const activeChat = useChatStore((state) => state.activeChat);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -283,11 +283,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<ConfirmModal <ConfirmModal
open={showDeleteConfirm} open={showDeleteConfirm}
message={ message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
isFavorites ? t('clearHistoryConfirm') :
chat.type === 'group' ? t('deleteGroupChatConfirm') :
t('deleteChatConfirm')
}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}
/> />
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
// Refs and logic for tracking session's first unread message to show the divider exactly once per load // Refs and logic for tracking session's first unread message to show the divider exactly once per load
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null }); const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) { // Update sessionUnreadRef when chat changes OR when messages are marked as read
const firstUnreadMsg = chatMessages.find( useEffect(() => {
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id) if (!activeChat || isLoadingMessages) return;
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null }; // Reset on chat change
} if (activeChat !== sessionUnreadRef.current.chatId) {
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
} else {
// Update if the first unread message was read (msgId no longer exists in unread list)
const firstUnreadMsg = chatMessages.find(
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
);
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
// All messages are now read
sessionUnreadRef.current.msgId = null;
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
// First unread changed (some messages were read)
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
}
}
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
const initialScrollChatId = useRef<string | null>(null); const initialScrollChatId = useRef<string | null>(null);
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{ {
root: scrollContainerRef.current, root: scrollContainerRef.current,
threshold: 0.1, threshold: 0.1,
rootMargin: '0px',
} }
); );
@@ -584,7 +604,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
unreadElements.forEach((el: Element) => { unreadElements.forEach((el: Element) => {
const id = el.getAttribute('data-message-id'); const id = el.getAttribute('data-message-id');
if (id && !sentReadIdsRef.current.has(id)) { if (id && !sentReadIdsRef.current.has(id)) {
observer.observe(el); // Check if element is already visible
const rect = el.getBoundingClientRect();
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
if (isVisible) {
// Mark as read immediately without waiting for intersection
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
if (seqId > 0) {
socket.emit('read_messages', {
chatId: activeChat,
lastReadMessageId: id,
lastReadSequenceId: seqId,
});
useChatStore.getState().markRead(activeChat, user.id, seqId);
sentReadIdsRef.current.add(id);
}
} else {
observer.observe(el);
}
} }
}); });
}; };
@@ -1060,7 +1099,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
setShowTopMenu(false); setShowTopMenu(false);
if (activeChat) { if (activeChat) {
setConfirmAction({ setConfirmAction({
message: chat.type === 'group' ? t('deleteGroupChatConfirm') : t('deleteChatConfirm'), message: t('deleteChatConfirm'),
action: async () => { action: async () => {
try { try {
await ChatApi.deleteChat(activeChat); await ChatApi.deleteChat(activeChat);
File diff suppressed because it is too large Load Diff