Compare commits

..
4 Commits
Author SHA1 Message Date
Andrey e1cda4dd8b 🔖 release: 1.12.0 2026-09-17 12:29:02 +03:00
AndreyandClaude Opus 5 fed079e02d ✨ feat(notifications): открытый диалог гасит свои уведомления
Прочтением считалось только нажатие на строку в шторке. Сотрудник мог уже
отвечать клиенту, а оклик «клиент ждёт» по этому же диалогу продолжал висеть
непрочитанным и держать счётчик в шапке. Счётчик переставал что-либо значить:
он звал туда, где человек уже был.

Теперь при открытии диалога гасятся все уведомления о нём — не только те,
что клиент успел загрузить в шторку: в неё помещается полсотни последних, и
гасить по загруженному списку означало бы оставлять хвост. Отбор идёт по
адресу уведомления, включая маршруты, которыми на диалог ссылались старые
строки в базе.

Отметка прочтения теперь шлёт сотруднику событие: счётчик живёт в шапке
каждой открытой вкладки, и без события они разъезжались до следующего опроса.

Проверено: тесты уведомлений (гасится только свой диалог, гасятся все его
уведомления, старый маршрут узнаётся, нечисловой идентификатор отклоняется),
рассылка событий, каталоги переводов, ruff, проверка типов рабочего места.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:01 +03:00
AndreyandClaude Opus 5 89c44db2bc 🐛 fix(chat): точка режима держится на аватаре в любой строке диалога
Строка списка — flex, и её ячейки по умолчанию растягиваются на всю высоту.
Обёртка аватара растягивалась вместе с ними, а точка режима привязана к её
низу — в строке с метками или таймером ожидания обёртка вырастала с 38 до 62
пикселей, и точка уезжала под аватар на четверть сантиметра. Чем больше меток
у диалога, тем дальше.

Обёртка прижата к началу поперечной оси и снова ростом с аватар, поэтому
точка стоит в его углу независимо от того, что показано в строке ниже.

Проверено на короткой строке и на строке с меткой группы и таймером: высота
обёртки 38 в обеих, точка на 1 пиксель за краем аватара.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:43:00 +03:00
AndreyandClaude Opus 5 ca23cd285f 🐛 fix(vk): марка ВКонтакте вместо чужого значка в ленте и карточке
Канал ВКонтакте появился, а марки у него не было: в ленте диалогов и в
карточке контакта рисовался общий пузырь сообщения, а в строке логина —
значок отправки, который для Telegram выглядел уместно только по
совпадению. Клиент из ВКонтакте выглядел пришедшим непонятно откуда.

Теперь `ChannelGlyph` знает ВКонтакте и отдаёт тот же официальный
монохромный глиф, что стоит на плитке подключения, а строка логина берёт
марку своего канала вместо общего значка: Telegram, MAX и ВКонтакте —
каждый свой.

Проверено: глифы на 13-18px в тёмной теме рядом с Telegram и MAX, лента
диалогов, строка логина и плитки каналов в карточке контакта; tsc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:38:40 +03:00
12 changed files with 161 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
@@ -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)
+80 -1
View File
@@ -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]);
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { ChannelGlyph } from "../../../../shared/badges";
import { Icon } from "../../../../shared/icons";
import { CopyButton } from "../../../../shared/ui-controls";
import { providerMeta } from "../../../../shared/providers";
@@ -73,10 +74,12 @@ export function ClientContext({
}
}
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; text: string; copy?: string; muted?: boolean }> = [];
// Логин показывается маркой своего канала: общий значок отправки годился,
// пока каналов с логином было два, и врал уже на третьем.
const fields: Array<{ key: string; icon: Parameters<typeof Icon>[0]["name"]; glyph?: ReactNode; text: string; copy?: string; muted?: boolean }> = [];
if (phone) fields.push({ key: "phone", icon: "phone", text: phone, copy: phone });
if (dialog.channel === "EMAIL" && email) fields.push({ key: "email", icon: "mail", text: email, copy: email });
if (username) fields.push({ key: "username", icon: "send", text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (username) fields.push({ key: "username", icon: "send", glyph: <ChannelGlyph provider={dialog.channel} size={15} />, text: `@${username} · ${channel.label}`, copy: `@${username}` });
if (isGuest) fields.push({ key: "guest", icon: "message", text: t("sales.anonymous_session", { channel: channel.label, connection: detail?.connection?.name ?? t("sales.widget") }), muted: true });
if (fields.length === 0 && detail?.connection) fields.push({ key: "connection", icon: "plug", text: `${channel.label} · ${detail.connection.name}`, muted: true });
// Компания и город — из карточки контакта (решение 5), без «копировать».
@@ -109,7 +112,7 @@ export function ClientContext({
<div className="ctx-contact-fields">
{fields.map((field) => (
<div className={`ctx-contact-field ${field.muted ? "is-muted" : ""}`} key={field.key}>
<span><Icon name={field.icon} size={15} /></span>
<span>{field.glyph ?? <Icon name={field.icon} size={15} />}</span>
<span>{field.text}</span>
{field.copy && <CopyButton value={field.copy} />}
</div>
@@ -199,6 +199,11 @@
.sales-dialog-avatar-wrap {
position: relative;
/* Обёртка обязана быть ростом с аватар. Строка — flex, и по умолчанию её
ячейки растягиваются на всю высоту: у строки с метками обёртка вырастала
вдвое, а точка режима, привязанная к её низу, уезжала под аватар. */
align-self: flex-start;
display: inline-flex;
flex: none;
margin-top: 20px;
}
+4 -3
View File
@@ -1,12 +1,13 @@
import { Icon, MaxLogo, TelegramLogo } from "./icons";
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "./icons";
import { providerKey } from "./providers";
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram/MAX —
* фирменные глифы, Email/Web — линейные. */
/** Иконка канала без подложки (дизайн-базлайн v2, решение 4): Telegram, MAX и
* ВКонтакте — фирменные глифы, Email/Web — линейные. */
export function ChannelGlyph({ provider, size = 13 }: { provider: string; size?: number }) {
const key = providerKey(provider);
if (key === "TG") return <TelegramLogo size={size} />;
if (key === "MAX") return <MaxLogo size={size} />;
if (key === "VK") return <VkLogo size={size} />;
if (key === "EMAIL") return <Icon name="mail" size={size} />;
return <Icon name="message" size={size} />;
}