mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-15 08:38:36 +01:00
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:
parent
df394b3b7d
commit
fac9b2e4ac
@ -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} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user