Add read-marker line in chat: store last-seen timestamps and render subtle horizontal line after last-read message (channels & DMs)

This commit is contained in:
DGSoft 2025-12-12 12:53:52 +01:00
parent df394b3b7d
commit fac9b2e4ac
3 changed files with 122 additions and 84 deletions

View File

@ -33,6 +33,7 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { user: currentUser } = useAuth();
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
useEffect(() => {
loadMessages();
@ -88,6 +89,32 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ 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<DirectMessageViewProps> = ({ user }) => {
<div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900">
{messages.map((message) => {
const isOwnMessage = message.sender_id === currentUser?.id;
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
return (
<div key={message.id} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
<React.Fragment key={message.id}>
<div className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{/* Profile Picture / Initials */}
{message.sender_profile_picture ? (
<img
@ -198,7 +227,11 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
</div>
</div>
</div>
</div>
</div>
{markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
);
})}
<div ref={messagesEndRef} />

View File

@ -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<MessageListProps> = ({ channelId, onReply }) => {
const { user } = useAuth();
const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const { getLastReadTimestamp } = useUnreadMessages();
const [messages, setMessages] = useState<Message[]>([]);
const [lastReadIndex, setLastReadIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
@ -121,6 +120,31 @@ const MessageList: React.FC<MessageListProps> = ({ 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<MessageListProps> = ({ channelId, onReply }) => {
<span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span>
</div>
)}
{(() => {
const lastReadTimestamp = getLastReadTimestamp('channel', channelId);
const hasUnreadMessages = messages.some(msg => {
const messageDate = new Date(msg.created_at);
return lastReadTimestamp && messageDate > lastReadTimestamp;
});
return hasUnreadMessages ? (
<div className="flex items-center my-4">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<div className="px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600">
Gelesen bis hier
</div>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
) : 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 (
<div
key={message.id}
id={`message-${message.id}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`}
>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? (
<img
src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
)}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1">
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_full_name || message.sender_username || 'Unknown'}
</span>
{message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
<React.Fragment key={message.id}>
<div
id={`message-${message.id}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`}
>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? (
<img
src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
)}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1">
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_full_name || message.sender_username || 'Unknown'}
</span>
{message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span>
</div>
</div>
</div>
{markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
);
}
const markerAfter = lastReadIndex !== null && messages[lastReadIndex] && messages[lastReadIndex].id === message.id;
return (
<div
key={message.id}
id={`message-${message.id}`}
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
>
<React.Fragment key={message.id}>
<div
id={`message-${message.id}`}
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? (
<img
@ -771,8 +784,12 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
)}
</div>
</div>
</div>
</div>
</div>
{markerAfter && (
<div className="w-full h-px bg-gray-300/40 dark:bg-gray-600/30 my-2" />
)}
</React.Fragment>
);
})}
<div ref={messagesEndRef} />

View File

@ -6,7 +6,6 @@ interface UnreadMessagesContextType {
unreadDirectMessages: Set<number>;
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<UnreadMessagesContextType | undefined>(undefined);
@ -37,7 +35,6 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
const [unreadDirectMessages, setUnreadDirectMessages] = useState<Set<number>>(new Set());
const [activeChannelId, setActiveChannelId] = useState<number | null>(null);
const [activeDirectMessageUserId, setActiveDirectMessageUserId] = useState<number | null>(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<UnreadMessagesProviderProps> = ({
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<UnreadMessagesProviderProps> = ({
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<UnreadMessagesProviderProps> = ({
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<UnreadMessagesProviderProps> = ({
return unreadDirectMessages.has(userId);
};
const getLastReadTimestamp = (type: 'channel' | 'dm', id: number): Date | null => {
const key = `${type}-${id}`;
return lastReadTimestamps[key] || null;
};
return (
<UnreadMessagesContext.Provider
value={{
@ -130,7 +120,6 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
unreadDirectMessages,
activeChannelId,
activeDirectMessageUserId,
lastReadTimestamps,
markChannelAsRead,
markChannelAsUnread,
markDirectMessageAsRead,
@ -139,7 +128,6 @@ export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({
hasUnreadDirectMessage,
setActiveChannel,
setActiveDirectMessage,
getLastReadTimestamp,
}}
>
{children}