mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(notifications): открытый диалог гасит свои уведомления
Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить: он звал туда, где человек уже был. Теперь при открытии диалога гасятся все уведомления о нём — не только те, что клиент успел загрузить в шторку: в неё помещается полсотни последних, и гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по адресу уведомления, включая маршруты, которыми на диалог ссылались старые строки в базе. Отметка прочтения теперь шлёт сотруднику событие: счётчик живёт в шапке каждой открытой вкладки, и без события они разъезжались до следующего опроса. Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется), рассылка событий, каталоги переводов, ruff, проверка типов рабочего места. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89c44db2bc
commit
fed079e02d
8 files changed
+144
-3
No files matched your search
@@ -357,6 +357,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.role_conflict": "User already has a different role in this organization",
|
||||
"identity.token_required": "token is required",
|
||||
"identity.totp_challenge_inactive": "TOTP challenge is not active",
|
||||
"notifications.conversation_id_number": "conversationId must be a number",
|
||||
"notifications.ids_list_or_all": "ids must be a list or use all=true",
|
||||
"notifications.push_types_list": "pushTypes must be a list",
|
||||
"notifications.enabled_bool": "enabled must be true or false",
|
||||
|
||||
@@ -361,6 +361,7 @@ MESSAGES: dict[str, object] = {
|
||||
"identity.role_conflict": "У пользователя уже другая роль в этой организации",
|
||||
"identity.token_required": "Нужен токен",
|
||||
"identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново",
|
||||
"notifications.conversation_id_number": "conversationId должен быть числом",
|
||||
"notifications.ids_list_or_all": "ids — список, либо all=true",
|
||||
"notifications.push_types_list": "pushTypes — список",
|
||||
"notifications.enabled_bool": "enabled — да или нет",
|
||||
|
||||
@@ -12,6 +12,7 @@ from chatballs.notifications.models import (
|
||||
NotificationRead,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.realtime import notify_notifications_changed
|
||||
from chatballs.notifications.selectors import unread_for
|
||||
|
||||
# Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события —
|
||||
@@ -131,9 +132,32 @@ def notify_management(*, context, dedup_key: str = "", **notification_data) -> i
|
||||
return created
|
||||
|
||||
|
||||
def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False) -> int:
|
||||
# Маршруты, которыми уведомление ссылается на диалог. Кроме нынешнего «chat»
|
||||
# здесь старые значения: они лежат в уже созданных строках, и открытый диалог
|
||||
# обязан гасить и их тоже.
|
||||
CONVERSATION_ROUTES = ("chat", "salesDialogs", "conversations")
|
||||
|
||||
|
||||
def mark_read(
|
||||
*,
|
||||
context,
|
||||
ids: list[int] | None = None,
|
||||
all_unread: bool = False,
|
||||
conversation_id: int | None = None,
|
||||
) -> int:
|
||||
"""Отметить уведомления прочитанными: перечисленные, все или про диалог.
|
||||
|
||||
Про диалог — потому что открытый диалог и есть прочтение: оклик «клиент
|
||||
ждёт» бессмысленно висеть непрочитанным, когда сотрудник уже в переписке.
|
||||
Гасятся все уведомления об этом диалоге, а не только те, что клиент успел
|
||||
загрузить в шторку.
|
||||
"""
|
||||
queryset = unread_for(context)
|
||||
if not all_unread:
|
||||
if conversation_id is not None:
|
||||
queryset = queryset.filter(
|
||||
target_route__in=CONVERSATION_ROUTES, target_id=str(conversation_id)
|
||||
)
|
||||
elif not all_unread:
|
||||
queryset = queryset.filter(id__in=ids or [])
|
||||
rows = [
|
||||
NotificationRead(
|
||||
@@ -144,4 +168,8 @@ def mark_read(*, context, ids: list[int] | None = None, all_unread: bool = False
|
||||
for notification in queryset
|
||||
]
|
||||
NotificationRead.objects.bulk_create(rows, ignore_conflicts=True)
|
||||
if rows:
|
||||
# Счётчик непрочитанных живёт в шапке каждой открытой вкладки: без
|
||||
# события они разъезжаются до следующего опроса.
|
||||
notify_notifications_changed([context.actor_user.id])
|
||||
return len(rows)
|
||||
@@ -13,10 +13,11 @@ from chatballs.notifications.delivery import NOTIFICATION_CREATED
|
||||
from chatballs.notifications.models import (
|
||||
MessengerBinding,
|
||||
MessengerBindingCode,
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
)
|
||||
from chatballs.notifications.selectors import visible_for
|
||||
from chatballs.notifications.selectors import unread_for, visible_for
|
||||
from chatballs.notifications.services import notify
|
||||
from chatballs.testing import TenantAPIClient as APIClient
|
||||
from chatballs.testing import tenant_context_for
|
||||
@@ -217,6 +218,84 @@ class PollerSelectionTests(NotifierTestBase):
|
||||
self.assertNotIn("inactive-channel-client", polled_names)
|
||||
|
||||
|
||||
class OpenedConversationReadTests(NotifierTestBase):
|
||||
"""Открытый диалог гасит свои уведомления.
|
||||
|
||||
Раньше оклик «клиент ждёт» оставался непрочитанным, пока по нему не нажали
|
||||
в шторке: сотрудник мог отвечать в переписке, а счётчик в шапке продолжал
|
||||
звать его туда же.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username="owner@example.com", password="temporary-password")
|
||||
|
||||
def _notify(self, *, target_id: str, type=NotificationType.OPERATOR_REQUESTED):
|
||||
return notify(
|
||||
context=self.context,
|
||||
type=type,
|
||||
audience=NotificationAudience.OPERATORS,
|
||||
title="Клиент ждёт",
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
def test_opened_conversation_marks_only_its_own_notifications(self) -> None:
|
||||
opened = self._notify(target_id="17")
|
||||
another = self._notify(target_id="18")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["unreadCount"], 1)
|
||||
unread = list(unread_for(self.context).values_list("id", flat=True))
|
||||
self.assertEqual(unread, [another.id])
|
||||
self.assertNotIn(opened.id, unread)
|
||||
|
||||
def test_every_notification_about_the_conversation_is_marked(self) -> None:
|
||||
# В шторку помещается не всё: гасим по диалогу, а не по тому, что успел
|
||||
# загрузить клиент.
|
||||
waiting = self._notify(target_id="17")
|
||||
message = self._notify(target_id="17", type=NotificationType.DIALOG_NEW_MESSAGE)
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
read = set(unread_for(self.context).values_list("id", flat=True))
|
||||
self.assertNotIn(waiting.id, read)
|
||||
self.assertNotIn(message.id, read)
|
||||
|
||||
def test_legacy_route_of_old_notifications_is_recognized(self) -> None:
|
||||
old = self._notify(target_id="17")
|
||||
Notification.objects.filter(id=old.id).update(target_route="salesDialogs")
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": 17},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(unread_for(self.context).count(), 0)
|
||||
|
||||
def test_broken_conversation_id_is_rejected(self) -> None:
|
||||
self._notify(target_id="17")
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/notifications/read/",
|
||||
data={"conversationId": "17"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(unread_for(self.context).count(), 1)
|
||||
|
||||
class BindingApiTests(NotifierTestBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
@@ -129,8 +129,14 @@ class NotificationReadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
conversation_id = request.data.get("conversationId")
|
||||
if request.data.get("all"):
|
||||
mark_read(context=request.tenant_context, all_unread=True)
|
||||
elif conversation_id is not None:
|
||||
# Сотрудник открыл диалог — окликать по нему больше нечем.
|
||||
if not isinstance(conversation_id, int) or isinstance(conversation_id, bool):
|
||||
return Response({"detail": t("notifications.conversation_id_number")}, status=400)
|
||||
mark_read(context=request.tenant_context, conversation_id=conversation_id)
|
||||
else:
|
||||
ids = request.data.get("ids")
|
||||
if not isinstance(ids, list):
|
||||
|
||||
@@ -46,6 +46,7 @@ import type { ConversationListItem, ListSort, ListTab } from "./types";
|
||||
import { useConversationCall } from "./useConversationCall";
|
||||
import { useConversationEvents } from "./useConversationEvents";
|
||||
import { useDebounced } from "../../shared/useDebounced";
|
||||
import { useOpenedConversationRead } from "../notifications/useOpenedConversationRead";
|
||||
import { useConversationHistory } from "./useConversationHistory";
|
||||
import { useConversationList } from "./useConversationList";
|
||||
import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
|
||||
@@ -148,6 +149,8 @@ export function ConversationWorkspace({ isOwner = false, canDelete = false, view
|
||||
setCtxOpen(false);
|
||||
}, [selectedId]);
|
||||
|
||||
useOpenedConversationRead(selectedId);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId == null) return;
|
||||
setDetail(null);
|
||||
|
||||
@@ -24,6 +24,10 @@ export const LEVEL_META: Record<NotificationLevel, { color: string; icon: "bell"
|
||||
export const fetchNotifications = () => api<{ items: AppNotification[]; unreadCount: number }>("/api/v1/notifications/");
|
||||
export const markRead = (ids: number[]) => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ ids }) });
|
||||
export const markAllRead = () => api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ all: true }) });
|
||||
// Открытый диалог — тоже прочтение: гасит все уведомления о нём, а не только
|
||||
// те, что попали в шторку.
|
||||
export const markConversationRead = (conversationId: number) =>
|
||||
api("/api/v1/notifications/read/", { method: "POST", body: JSON.stringify({ conversationId }) });
|
||||
|
||||
export type NotificationTransport = "BROWSER" | "MESSENGER";
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { markConversationRead } from "./model";
|
||||
|
||||
/** Открытый диалог гасит свои уведомления.
|
||||
*
|
||||
* Раньше оклик «клиент ждёт» оставался непрочитанным, даже когда сотрудник уже
|
||||
* отвечал в этой переписке: прочтением считалось только нажатие на строку в
|
||||
* шторке. Счётчик в шапке жил своей жизнью и переставал что-либо значить.
|
||||
*
|
||||
* Список уведомлений после этого перезапрашивает шапка — по событию, которое
|
||||
* шлёт сервер тому же сотруднику: вкладок у него может быть несколько.
|
||||
*/
|
||||
export function useOpenedConversationRead(conversationId: number | null): void {
|
||||
useEffect(() => {
|
||||
if (conversationId == null) return;
|
||||
void markConversationRead(conversationId).catch(() => undefined);
|
||||
}, [conversationId]);
|
||||
}
|
||||
Reference in new issue
Block a user