chore: snapshot current messages and notifications work

This commit is contained in:
lizerk 2026-04-16 12:07:53 +03:00
parent d412d6634b
commit ccad715e36
5 changed files with 93 additions and 2 deletions

View File

@ -24,6 +24,7 @@ class ChatNotificationReadService:
NotificationVerbs.MENTION,
NotificationVerbs.REPLY,
NotificationVerbs.ADDED_TO_CHAT,
NotificationVerbs.COMMENTED,
)
def mark_chat_notifications_as_read(

View File

@ -150,6 +150,55 @@ class TestUserConsumerChatManagement:
assert unrelated_notification.unread is True
await communicator.disconnect()
async def test_open_chat_marks_related_commented_notifications_as_read(self, ws_communicator, user, test_chat):
"""Открытие comments-чата должно читать уведомления verb=commented этого чата."""
related_notification = await database_sync_to_async(Notification.objects.create)(
recipient=user,
verb='commented',
description='Department chat comment notification',
action_url=f'/messages/{test_chat.id}',
data={'chat_id': test_chat.id, 'message_id': 12, 'object_type': 'Department'},
)
unrelated_notification = await database_sync_to_async(Notification.objects.create)(
recipient=user,
verb='commented',
description='Other comments chat notification',
action_url='/messages/999',
data={'chat_id': 999, 'message_id': 13, 'object_type': 'Department'},
)
communicator = await ws_communicator(user=user)
connected, _ = await communicator.connect()
assert connected
await communicator.send_json_to({
'action': 'open_chat',
'chat_id': test_chat.id,
'load_history': False,
})
response = await communicator.receive_json_from(timeout=5)
assert response['type'] == 'chat_opened'
assert response['chat_id'] == test_chat.id
read_all_event = await communicator.receive_json_from(timeout=5)
assert read_all_event['type'] == 'notifications_read_all'
assert related_notification.id in read_all_event['notification_ids']
assert unrelated_notification.id not in read_all_event['notification_ids']
count_event = await communicator.receive_json_from(timeout=5)
assert count_event['type'] == 'unread_count'
assert count_event['count'] == 1
related_notification = await database_sync_to_async(Notification.objects.get)(id=related_notification.id)
unrelated_notification = await database_sync_to_async(Notification.objects.get)(id=unrelated_notification.id)
assert related_notification.unread is False
assert unrelated_notification.unread is True
await communicator.disconnect()
async def test_open_nonexistent_chat(self, ws_communicator, user):
"""Попытка открыть несуществующий чат возвращает ошибку."""

View File

@ -378,6 +378,24 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
color: #ffffff;
}
.chat-list-unread-badge {
min-height: 1.35rem;
border: 2px solid var(--surface-primary);
box-shadow: 0 4px 10px color-mix(in srgb, #ef4444 22%, transparent);
letter-spacing: -0.01em;
}
.chat-unread-divider {
color: var(--accent-primary-strong);
}
.chat-unread-divider__label {
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, var(--border-subtle));
background: color-mix(in srgb, var(--accent-primary) 10%, var(--surface-primary));
color: var(--accent-primary-strong);
box-shadow: 0 6px 16px color-mix(in srgb, var(--accent-primary) 10%, transparent);
}
.app-action-primary {
border: 1px solid transparent;
background: var(--accent-primary);

View File

@ -135,6 +135,19 @@ function MessageDialogPageContent() {
/* ── derived state ── */
const displayMessages = useMemo(() => mergeDisplayMessages(cm.messages, cm.pendingMessages), [cm.messages, cm.pendingMessages]);
const messagesById = useMemo(() => new Map(displayMessages.map(m => [m.id, m])), [displayMessages]);
const unreadDividerMessageId = useMemo(() => {
if (cm.newMessagesBelowCount <= 0) {
return null;
}
const unreadBelowIds = cm.unreadBelowIdsRef.current;
if (!unreadBelowIds || unreadBelowIds.size === 0) {
return null;
}
const firstUnreadIncoming = displayMessages.find((message) => unreadBelowIds.has(message.id));
return firstUnreadIncoming?.id ?? null;
}, [cm.newMessagesBelowCount, cm.unreadBelowIdsRef, displayMessages]);
/**
* Badge count: take the maximum of the server-provided unread_count
@ -1057,6 +1070,7 @@ function MessageDialogPageContent() {
const prevDay = index > 0 ? getMessageDayKey(displayMessages[index - 1]) : null;
const curDate = getMessageDate(message);
const showDayDivider = Boolean(curDate && curDay !== prevDay);
const showUnreadDivider = unreadDividerMessageId === message.id;
return (
<React.Fragment key={message.id}>
@ -1067,6 +1081,15 @@ function MessageDialogPageContent() {
</button>
</div>
) : null}
{showUnreadDivider ? (
<div className="chat-unread-divider mb-3 mt-1 flex items-center gap-3 px-2" role="separator" aria-label="Непрочитанные сообщения">
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
<span className="chat-unread-divider__label rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em]">
Непрочитанные сообщения
</span>
<span className="h-px flex-1 bg-[color:color-mix(in_srgb,var(--accent-primary)_18%,var(--border-subtle))]" />
</div>
) : null}
<ChatMessageItem
message={message}
currentUserId={user?.id}

View File

@ -523,7 +523,7 @@ export default function MessagesPage() {
}`}
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10">
<div className="relative h-10 w-10 overflow-visible">
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{chatAvatar ? (
<Image
@ -544,7 +544,7 @@ export default function MessagesPage() {
</span>
{/* Счетчик непрочитанных */}
{(chat.unread_count ?? 0) > 0 ? (
<span className="app-counter-danger absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center border-2 border-[var(--surface-primary)] px-1.5 text-[10px] font-bold">
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
</span>
) : null}