From fac9b2e4ac296db3b7b44f65327d0de0d2f29425 Mon Sep 17 00:00:00 2001 From: DGSoft Date: Fri, 12 Dec 2025 12:53:52 +0100 Subject: [PATCH] Add read-marker line in chat: store last-seen timestamps and render subtle horizontal line after last-read message (channels & DMs) --- .../src/components/Chat/DirectMessageView.tsx | 39 +++++- frontend/src/components/Chat/MessageList.tsx | 131 ++++++++++-------- .../src/contexts/UnreadMessagesContext.tsx | 36 ++--- 3 files changed, 122 insertions(+), 84 deletions(-) diff --git a/frontend/src/components/Chat/DirectMessageView.tsx b/frontend/src/components/Chat/DirectMessageView.tsx index 7730669..ca86853 100644 --- a/frontend/src/components/Chat/DirectMessageView.tsx +++ b/frontend/src/components/Chat/DirectMessageView.tsx @@ -33,6 +33,7 @@ const DirectMessageView: React.FC = ({ user }) => { const [sending, setSending] = useState(false); const messagesEndRef = useRef(null); const { user: currentUser } = useAuth(); + const [lastReadIndex, setLastReadIndex] = useState(null); useEffect(() => { loadMessages(); @@ -88,6 +89,32 @@ const DirectMessageView: React.FC = ({ user }) => { scrollToBottom(); }, [messages]); + // compute last-read index for direct messages using is_read or stored last-seen timestamp + useEffect(() => { + try { + const lastSeenIso = localStorage.getItem(`dm_last_seen_${user.id}`); + const lastSeen = lastSeenIso ? new Date(lastSeenIso) : null; + let idx: number | null = null; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + const created = new Date(msg.created_at); + // Consider message read if it's sent by current user, or marked is_read (i.e., receiver has read it), + // or its created_at is <= lastSeen timestamp + if ( + (currentUser && msg.sender_id === currentUser.id) || + msg.is_read || + (lastSeen && created <= lastSeen) + ) { + idx = i; + break; + } + } + setLastReadIndex(idx); + } catch (e) { + setLastReadIndex(null); + } + }, [messages, user.id, currentUser]); + const loadMessages = async () => { try { const data = await directMessagesAPI.getConversation(user.id); @@ -154,10 +181,12 @@ const DirectMessageView: React.FC = ({ user }) => {
{messages.map((message) => { const isOwnMessage = message.sender_id === currentUser?.id; + const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id; return ( -
-
+ +
+
{/* Profile Picture / Initials */} {message.sender_profile_picture ? ( = ({ user }) => {
-
+
+ {markerAfter && ( +
+ )} + ); })}
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 0636ffe..711da62 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -5,7 +5,6 @@ import CodeBlock from '../common/CodeBlock'; import { useAuth } from '../../contexts/AuthContext'; import { useToast } from '../../contexts/ToastContext'; import { useUserStatus } from '../../contexts/UserStatusContext'; -import { useUnreadMessages } from '../../contexts/UnreadMessagesContext'; import UserStatusIndicator from '../common/UserStatusIndicator'; interface MessageListProps { @@ -17,8 +16,8 @@ const MessageList: React.FC = ({ channelId, onReply }) => { const { user } = useAuth(); const { addToast } = useToast(); const { getUserStatus } = useUserStatus(); - const { getLastReadTimestamp } = useUnreadMessages(); const [messages, setMessages] = useState([]); + const [lastReadIndex, setLastReadIndex] = useState(null); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); const [openMenuId, setOpenMenuId] = useState(null); @@ -121,6 +120,31 @@ const MessageList: React.FC = ({ channelId, onReply }) => { scrollToBottom(); }, [messages]); + // compute last-read index based on stored last-seen timestamp for this channel + useEffect(() => { + try { + const lastSeenIso = localStorage.getItem(`channel_last_seen_${channelId}`); + if (!lastSeenIso) { + setLastReadIndex(null); + return; + } + const lastSeen = new Date(lastSeenIso); + // find last message with created_at <= lastSeen or message sent by current user + let idx: number | null = null; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + const created = new Date(msg.created_at); + if ((user && msg.sender_id === user.id) || created <= lastSeen) { + idx = i; + break; + } + } + setLastReadIndex(idx); + } catch (e) { + setLastReadIndex(null); + } + }, [messages, channelId, user]); + const loadMessages = async (append = false) => { try { const offset = append ? messages.length : 0; @@ -274,75 +298,64 @@ const MessageList: React.FC = ({ channelId, onReply }) => { Lade ältere Nachrichten...
)} - {(() => { - const lastReadTimestamp = getLastReadTimestamp('channel', channelId); - const hasUnreadMessages = messages.some(msg => { - const messageDate = new Date(msg.created_at); - return lastReadTimestamp && messageDate > lastReadTimestamp; - }); - - return hasUnreadMessages ? ( -
-
-
- Gelesen bis hier -
-
-
- ) : null; - })()} {messages.map((message) => { const isOwnMessage = user && message.sender_id === user.id; // Deleted message - simple text without bubble (check both deleted and is_deleted) if (message.deleted || message.is_deleted) { + const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id; return ( -
-
- {message.sender_profile_picture ? ( - {message.sender_username} - ) : ( -
- {getInitials(message.sender_full_name, message.sender_username)} -
- )} -
-
-
- - {message.sender_full_name || message.sender_username || 'Unknown'} - - {message.sender_id !== user?.id && ( - - )} + +
+
+ {message.sender_profile_picture ? ( + {message.sender_username} + ) : ( +
+ {getInitials(message.sender_full_name, message.sender_username)}
- - {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + )} +
+
+
+ + {message.sender_full_name || message.sender_username || 'Unknown'} + + {message.sender_id !== user?.id && ( + + )} +
+ + {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ + Diese Nachricht wurde gelöscht
- - Diese Nachricht wurde gelöscht -
-
+ {markerAfter && ( +
+ )} + ); } + const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id; return ( -
+ +
{message.sender_profile_picture ? ( = ({ channelId, onReply }) => { )}
+
-
+ {markerAfter && ( +
+ )} + ); })}
diff --git a/frontend/src/contexts/UnreadMessagesContext.tsx b/frontend/src/contexts/UnreadMessagesContext.tsx index 8905ca5..d47d4c6 100644 --- a/frontend/src/contexts/UnreadMessagesContext.tsx +++ b/frontend/src/contexts/UnreadMessagesContext.tsx @@ -6,7 +6,6 @@ interface UnreadMessagesContextType { unreadDirectMessages: Set; activeChannelId: number | null; activeDirectMessageUserId: number | null; - lastReadTimestamps: { [key: string]: Date }; // 'channel-{id}' or 'dm-{userId}' => timestamp markChannelAsRead: (channelId: number) => void; markChannelAsUnread: (channelId: number) => void; markDirectMessageAsRead: (userId: number) => void; @@ -15,7 +14,6 @@ interface UnreadMessagesContextType { hasUnreadDirectMessage: (userId: number) => boolean; setActiveChannel: (channelId: number | null) => void; setActiveDirectMessage: (userId: number | null) => void; - getLastReadTimestamp: (type: 'channel' | 'dm', id: number) => Date | null; } const UnreadMessagesContext = createContext(undefined); @@ -37,7 +35,6 @@ export const UnreadMessagesProvider: React.FC = ({ const [unreadDirectMessages, setUnreadDirectMessages] = useState>(new Set()); const [activeChannelId, setActiveChannelId] = useState(null); const [activeDirectMessageUserId, setActiveDirectMessageUserId] = useState(null); - const [lastReadTimestamps, setLastReadTimestamps] = useState<{ [key: string]: Date }>({}); const { user } = useAuth(); // Listen for unread message events from the presence WebSocket @@ -70,6 +67,12 @@ export const UnreadMessagesProvider: React.FC = ({ newSet.delete(channelId); return newSet; }); + try { + // store last seen timestamp locally so MessageList can render a read marker + localStorage.setItem(`channel_last_seen_${channelId}`, new Date().toISOString()); + } catch (e) { + // ignore storage errors + } }; const markChannelAsUnread = (channelId: number) => { @@ -82,6 +85,12 @@ export const UnreadMessagesProvider: React.FC = ({ newSet.delete(userId); return newSet; }); + try { + // store last seen timestamp for direct messages + localStorage.setItem(`dm_last_seen_${userId}`, new Date().toISOString()); + } catch (e) { + // ignore + } }; const markDirectMessageAsUnread = (userId: number) => { @@ -90,24 +99,10 @@ export const UnreadMessagesProvider: React.FC = ({ const setActiveChannel = (channelId: number | null) => { setActiveChannelId(channelId); - if (channelId !== null) { - // Set timestamp when channel becomes active - setLastReadTimestamps(prev => ({ - ...prev, - [`channel-${channelId}`]: new Date() - })); - } }; const setActiveDirectMessage = (userId: number | null) => { setActiveDirectMessageUserId(userId); - if (userId !== null) { - // Set timestamp when DM becomes active - setLastReadTimestamps(prev => ({ - ...prev, - [`dm-${userId}`]: new Date() - })); - } }; const hasUnreadChannel = (channelId: number) => { @@ -118,11 +113,6 @@ export const UnreadMessagesProvider: React.FC = ({ return unreadDirectMessages.has(userId); }; - const getLastReadTimestamp = (type: 'channel' | 'dm', id: number): Date | null => { - const key = `${type}-${id}`; - return lastReadTimestamps[key] || null; - }; - return ( = ({ unreadDirectMessages, activeChannelId, activeDirectMessageUserId, - lastReadTimestamps, markChannelAsRead, markChannelAsUnread, markDirectMessageAsRead, @@ -139,7 +128,6 @@ export const UnreadMessagesProvider: React.FC = ({ hasUnreadDirectMessage, setActiveChannel, setActiveDirectMessage, - getLastReadTimestamp, }} > {children}