feat(notifications): add messenger PWA delivery routing
This commit is contained in:
parent
f8919dcdbd
commit
fababde18c
@ -5,7 +5,12 @@ Admin для упрощенной системы уведомлений
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from .models import Notification, UserChannelPreferences
|
||||
from .models import (
|
||||
Notification,
|
||||
NotificationDeliveryRouting,
|
||||
NotificationInstallation,
|
||||
UserChannelPreferences,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
@ -286,3 +291,25 @@ class UserChannelPreferencesAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
get_disabled_verbs_count.short_description = "Отключено типов"
|
||||
|
||||
|
||||
@admin.register(NotificationInstallation)
|
||||
class NotificationInstallationAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"app_kind",
|
||||
"device_name",
|
||||
"installed",
|
||||
"active",
|
||||
"last_seen_at",
|
||||
)
|
||||
list_filter = ("app_kind", "installed", "active")
|
||||
search_fields = ("user__email", "device_name", "endpoint")
|
||||
readonly_fields = ("created_at", "updated_at", "last_seen_at")
|
||||
|
||||
|
||||
@admin.register(NotificationDeliveryRouting)
|
||||
class NotificationDeliveryRoutingAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "device_group_id", "updated_at")
|
||||
search_fields = ("user__email", "device_group_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
@ -147,7 +147,38 @@ class SubscribePushRequestSerializer(serializers.Serializer):
|
||||
endpoint = serializers.CharField()
|
||||
keys = PushKeysSerializer()
|
||||
device_name = serializers.CharField(required=False)
|
||||
installation_id = serializers.UUIDField(required=False)
|
||||
device_group_id = serializers.UUIDField(required=False)
|
||||
app_kind = serializers.ChoiceField(
|
||||
choices=("main", "messenger"),
|
||||
required=False,
|
||||
default="main",
|
||||
)
|
||||
installed = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class UnsubscribePushRequestSerializer(serializers.Serializer):
|
||||
endpoint = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class InstallationHeartbeatRequestSerializer(serializers.Serializer):
|
||||
installation_id = serializers.UUIDField()
|
||||
device_group_id = serializers.UUIDField()
|
||||
app_kind = serializers.ChoiceField(choices=("main", "messenger"))
|
||||
device_name = serializers.CharField(required=False, allow_blank=True)
|
||||
installed = serializers.BooleanField(required=False, default=True)
|
||||
|
||||
|
||||
class DeliveryRoutingRequestSerializer(serializers.Serializer):
|
||||
device_group_id = serializers.UUIDField()
|
||||
routes = serializers.DictField(
|
||||
child=serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=("main", "messenger")),
|
||||
allow_empty=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DisconnectInstallationRequestSerializer(serializers.Serializer):
|
||||
device_group_id = serializers.UUIDField()
|
||||
app_kind = serializers.ChoiceField(choices=("main", "messenger"))
|
||||
|
||||
@ -35,4 +35,19 @@ urlpatterns = [
|
||||
path("push/vapid-key/", views.get_vapid_public_key, name="vapid_key"),
|
||||
path("push/subscribe/", views.subscribe_push, name="push_subscribe"),
|
||||
path("push/unsubscribe/", views.unsubscribe_push, name="push_unsubscribe"),
|
||||
path(
|
||||
"push/installations/heartbeat/",
|
||||
views.installation_heartbeat,
|
||||
name="push_installation_heartbeat",
|
||||
),
|
||||
path(
|
||||
"push/delivery-routing/",
|
||||
views.delivery_routing,
|
||||
name="push_delivery_routing",
|
||||
),
|
||||
path(
|
||||
"push/installations/disconnect/",
|
||||
views.disconnect_installation,
|
||||
name="push_installation_disconnect",
|
||||
),
|
||||
]
|
||||
|
||||
@ -8,15 +8,23 @@ API endpoints для системы уведомлений v2.0
|
||||
- UserChannelPreferences для настроек каналов
|
||||
"""
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from ..models import Notification, UserChannelPreferences
|
||||
from ..models import (
|
||||
Notification,
|
||||
NotificationDeliveryRouting,
|
||||
NotificationInstallation,
|
||||
UserChannelPreferences,
|
||||
)
|
||||
from ..routing import NOTIFICATION_CATEGORIES, normalize_routes
|
||||
from ..cache import (
|
||||
get_unread_count as get_cached_unread_count,
|
||||
get_unread_summary,
|
||||
@ -29,6 +37,9 @@ from ..realtime import (
|
||||
from .serializers import (
|
||||
ChannelPreferencesSerializer,
|
||||
CountResponseSerializer,
|
||||
DeliveryRoutingRequestSerializer,
|
||||
DisconnectInstallationRequestSerializer,
|
||||
InstallationHeartbeatRequestSerializer,
|
||||
MarkAllAsReadRequestSerializer,
|
||||
MarkCategoryAsReadRequestSerializer,
|
||||
NotificationsListResponseSerializer,
|
||||
@ -593,21 +604,22 @@ def subscribe_push(request):
|
||||
"""
|
||||
from push_notifications.models import WebPushDevice
|
||||
|
||||
endpoint = request.data.get('endpoint')
|
||||
keys = request.data.get('keys', {})
|
||||
device_name = request.data.get('device_name', 'unknown')
|
||||
|
||||
if not endpoint or not keys:
|
||||
return Response(
|
||||
{'status': 'error', 'message': 'endpoint and keys required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
serializer = SubscribePushRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data
|
||||
endpoint = payload['endpoint']
|
||||
keys = payload['keys']
|
||||
device_name = payload.get('device_name', 'unknown')
|
||||
app_kind = payload.get('app_kind', NotificationInstallation.APP_MAIN)
|
||||
generated_id = uuid.uuid5(uuid.NAMESPACE_URL, endpoint)
|
||||
installation_id = payload.get('installation_id', generated_id)
|
||||
device_group_id = payload.get('device_group_id', generated_id)
|
||||
|
||||
# Регистрируем устройство
|
||||
device, created = WebPushDevice.objects.update_or_create(
|
||||
user=request.user,
|
||||
registration_id=endpoint,
|
||||
defaults={
|
||||
'user': request.user,
|
||||
'p256dh': keys.get('p256dh', ''),
|
||||
'auth': keys.get('auth', ''),
|
||||
'browser': device_name,
|
||||
@ -615,6 +627,39 @@ def subscribe_push(request):
|
||||
}
|
||||
)
|
||||
|
||||
NotificationInstallation.objects.filter(endpoint=endpoint).exclude(
|
||||
user=request.user,
|
||||
).update(endpoint=None, push_device_id=None, active=False)
|
||||
|
||||
installation = NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
endpoint=endpoint,
|
||||
).first() or NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
device_group_id=device_group_id,
|
||||
app_kind=app_kind,
|
||||
).first()
|
||||
if installation is None:
|
||||
installation = NotificationInstallation(
|
||||
user=request.user,
|
||||
device_group_id=device_group_id,
|
||||
app_kind=app_kind,
|
||||
)
|
||||
elif installation.endpoint and installation.endpoint != endpoint:
|
||||
WebPushDevice.objects.filter(
|
||||
user=request.user,
|
||||
registration_id=installation.endpoint,
|
||||
).exclude(pk=device.pk).delete()
|
||||
|
||||
installation.installation_id = installation_id
|
||||
installation.endpoint = endpoint
|
||||
installation.push_device_id = device.id
|
||||
installation.device_name = device_name
|
||||
installation.installed = payload.get('installed', installation.installed)
|
||||
installation.active = True
|
||||
installation.last_seen_at = timezone.now()
|
||||
installation.save()
|
||||
|
||||
prefs, _ = UserChannelPreferences.objects.get_or_create(
|
||||
user=request.user,
|
||||
)
|
||||
@ -630,7 +675,192 @@ def subscribe_push(request):
|
||||
else 'Push subscription updated'
|
||||
),
|
||||
'created': created,
|
||||
'device_id': device.id
|
||||
'device_id': device.id,
|
||||
'installation_id': str(installation.installation_id),
|
||||
'app_kind': installation.app_kind,
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Notifications"],
|
||||
summary="Подтвердить активность установленного приложения",
|
||||
request=InstallationHeartbeatRequestSerializer,
|
||||
responses={200: StatusResponseSerializer},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def installation_heartbeat(request):
|
||||
serializer = InstallationHeartbeatRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data
|
||||
|
||||
installation, created = NotificationInstallation.objects.update_or_create(
|
||||
user=request.user,
|
||||
device_group_id=payload['device_group_id'],
|
||||
app_kind=payload['app_kind'],
|
||||
defaults={
|
||||
'installation_id': payload['installation_id'],
|
||||
'device_name': payload.get('device_name', ''),
|
||||
'installed': payload.get('installed', True),
|
||||
'active': True,
|
||||
'last_seen_at': timezone.now(),
|
||||
},
|
||||
)
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'created': created,
|
||||
'installation': _serialize_installation(installation),
|
||||
})
|
||||
|
||||
|
||||
def _serialize_installation(installation):
|
||||
return {
|
||||
'installation_id': str(installation.installation_id),
|
||||
'device_group_id': str(installation.device_group_id),
|
||||
'app_kind': installation.app_kind,
|
||||
'device_name': installation.device_name,
|
||||
'installed': installation.installed,
|
||||
'active': installation.active,
|
||||
'push_ready': installation.push_ready,
|
||||
'last_seen_at': installation.last_seen_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@extend_schema(
|
||||
methods=['GET'],
|
||||
tags=["Notifications"],
|
||||
summary="Получить маршруты доставки по приложениям",
|
||||
)
|
||||
@extend_schema(
|
||||
methods=['PUT'],
|
||||
tags=["Notifications"],
|
||||
summary="Обновить маршруты доставки по приложениям",
|
||||
request=DeliveryRoutingRequestSerializer,
|
||||
)
|
||||
@api_view(['GET', 'PUT'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def delivery_routing(request):
|
||||
if request.method == 'GET':
|
||||
raw_device_group_id = request.query_params.get('device_group_id')
|
||||
try:
|
||||
device_group_id = uuid.UUID(str(raw_device_group_id))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return Response(
|
||||
{'detail': 'device_group_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
serializer = DeliveryRoutingRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device_group_id = serializer.validated_data['device_group_id']
|
||||
|
||||
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
|
||||
user=request.user,
|
||||
device_group_id=device_group_id,
|
||||
)
|
||||
|
||||
if request.method == 'PUT':
|
||||
routes = normalize_routes(serializer.validated_data['routes'])
|
||||
messenger_requested = any(
|
||||
NotificationInstallation.APP_MESSENGER in destinations
|
||||
for destinations in routes.values()
|
||||
)
|
||||
if messenger_requested and not NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
device_group_id=device_group_id,
|
||||
app_kind=NotificationInstallation.APP_MESSENGER,
|
||||
active=True,
|
||||
installed=True,
|
||||
endpoint__isnull=False,
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
'detail': (
|
||||
'Сначала завершите установку мессенджера и '
|
||||
'разрешите ему уведомления.'
|
||||
)
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
routing.routes = routes
|
||||
routing.save(update_fields=['routes', 'updated_at'])
|
||||
|
||||
installations = NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
device_group_id=device_group_id,
|
||||
active=True,
|
||||
)
|
||||
return Response({
|
||||
'device_group_id': str(device_group_id),
|
||||
'categories': [
|
||||
{'key': key, 'label': label}
|
||||
for key, label in NOTIFICATION_CATEGORIES
|
||||
],
|
||||
'routes': normalize_routes(routing.routes),
|
||||
'installations': [
|
||||
_serialize_installation(installation)
|
||||
for installation in installations
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Notifications"],
|
||||
summary="Отключить приложение доставки на устройстве",
|
||||
request=DisconnectInstallationRequestSerializer,
|
||||
responses={200: StatusResponseSerializer},
|
||||
)
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def disconnect_installation(request):
|
||||
from push_notifications.models import WebPushDevice
|
||||
|
||||
serializer = DisconnectInstallationRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data
|
||||
installations = NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
device_group_id=payload['device_group_id'],
|
||||
app_kind=payload['app_kind'],
|
||||
)
|
||||
endpoints = list(
|
||||
installations.exclude(endpoint__isnull=True).values_list(
|
||||
'endpoint',
|
||||
flat=True,
|
||||
)
|
||||
)
|
||||
WebPushDevice.objects.filter(
|
||||
user=request.user,
|
||||
registration_id__in=endpoints,
|
||||
).delete()
|
||||
installations.update(
|
||||
endpoint=None,
|
||||
push_device_id=None,
|
||||
installed=False,
|
||||
active=False,
|
||||
last_seen_at=timezone.now(),
|
||||
)
|
||||
|
||||
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
|
||||
user=request.user,
|
||||
device_group_id=payload['device_group_id'],
|
||||
)
|
||||
routes = normalize_routes(routing.routes)
|
||||
if payload['app_kind'] == NotificationInstallation.APP_MESSENGER:
|
||||
routes = {
|
||||
category: (
|
||||
[app for app in destinations if app != 'messenger']
|
||||
or ['main']
|
||||
)
|
||||
for category, destinations in routes.items()
|
||||
}
|
||||
routing.routes = routes
|
||||
routing.save(update_fields=['routes', 'updated_at'])
|
||||
|
||||
return Response({
|
||||
'status': 'success',
|
||||
'routes': routes,
|
||||
'disconnected': installations.count(),
|
||||
})
|
||||
|
||||
|
||||
@ -650,6 +880,14 @@ def unsubscribe_push(request):
|
||||
endpoint = request.data.get('endpoint')
|
||||
|
||||
if endpoint:
|
||||
NotificationInstallation.objects.filter(
|
||||
user=request.user,
|
||||
endpoint=endpoint,
|
||||
).update(
|
||||
endpoint=None,
|
||||
push_device_id=None,
|
||||
last_seen_at=timezone.now(),
|
||||
)
|
||||
deleted_count, _ = WebPushDevice.objects.filter(
|
||||
user=request.user,
|
||||
registration_id=endpoint
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-05 11:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0012_split_regulation_notifications'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationDeliveryRouting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('device_group_id', models.UUIDField(db_index=True)),
|
||||
('routes', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_delivery_routings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notifications_delivery_routing',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'device_group_id'), name='notifications_unique_routing_per_device_group')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NotificationInstallation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('installation_id', models.UUIDField(default=uuid.uuid4)),
|
||||
('device_group_id', models.UUIDField(db_index=True)),
|
||||
('app_kind', models.CharField(choices=[('main', 'Основное приложение'), ('messenger', 'Мессенджер')], max_length=20)),
|
||||
('endpoint', models.URLField(blank=True, max_length=1000, null=True, unique=True)),
|
||||
('push_device_id', models.PositiveBigIntegerField(blank=True, null=True, unique=True)),
|
||||
('device_name', models.CharField(blank=True, max_length=255)),
|
||||
('installed', models.BooleanField(default=False)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('last_seen_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_installations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notifications_installation',
|
||||
'ordering': ('-last_seen_at',),
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'installation_id'), name='notifications_unique_installation_per_user'), models.UniqueConstraint(fields=('user', 'device_group_id', 'app_kind'), name='notifications_unique_app_per_device_group')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -16,6 +16,8 @@ TODO: Добавить тесты для моделей:
|
||||
- UserChannelPreferences.is_in_dnd_period()
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
@ -398,5 +400,78 @@ class UserChannelPreferences(models.Model):
|
||||
return now >= self.dnd_start_time or now <= self.dnd_end_time
|
||||
|
||||
|
||||
class NotificationInstallation(models.Model):
|
||||
"""An application installation paired with a Web Push subscription."""
|
||||
|
||||
APP_MAIN = "main"
|
||||
APP_MESSENGER = "messenger"
|
||||
APP_KIND_CHOICES = [
|
||||
(APP_MAIN, "Основное приложение"),
|
||||
(APP_MESSENGER, "Мессенджер"),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notification_installations",
|
||||
)
|
||||
installation_id = models.UUIDField(default=uuid.uuid4)
|
||||
device_group_id = models.UUIDField(db_index=True)
|
||||
app_kind = models.CharField(max_length=20, choices=APP_KIND_CHOICES)
|
||||
endpoint = models.URLField(max_length=1000, unique=True, null=True, blank=True)
|
||||
push_device_id = models.PositiveBigIntegerField(
|
||||
unique=True,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
device_name = models.CharField(max_length=255, blank=True)
|
||||
installed = models.BooleanField(default=False)
|
||||
active = models.BooleanField(default=True)
|
||||
last_seen_at = models.DateTimeField(default=timezone.now)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "notifications_installation"
|
||||
ordering = ("-last_seen_at",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "installation_id"),
|
||||
name="notifications_unique_installation_per_user",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "device_group_id", "app_kind"),
|
||||
name="notifications_unique_app_per_device_group",
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def push_ready(self):
|
||||
return self.active and bool(self.endpoint and self.push_device_id)
|
||||
|
||||
|
||||
class NotificationDeliveryRouting(models.Model):
|
||||
"""Per-device routing of notification categories between installed apps."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notification_delivery_routings",
|
||||
)
|
||||
device_group_id = models.UUIDField(db_index=True)
|
||||
routes = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "notifications_delivery_routing"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "device_group_id"),
|
||||
name="notifications_unique_routing_per_device_group",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# === Web Push устройства управляются через django-push-notifications ===
|
||||
# Используйте: from push_notifications.models import WebPushDevice
|
||||
|
||||
73
backend/notifications/routing.py
Normal file
73
backend/notifications/routing.py
Normal file
@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
APP_MAIN = "main"
|
||||
APP_MESSENGER = "messenger"
|
||||
VALID_APPS = {APP_MAIN, APP_MESSENGER}
|
||||
|
||||
NOTIFICATION_CATEGORIES = (
|
||||
("messages", "Сообщения"),
|
||||
("tasks", "Задачи и доски"),
|
||||
("requests", "Заявления"),
|
||||
("procurement", "Закупки"),
|
||||
("documents", "Документы и регламенты"),
|
||||
("guests", "Гости"),
|
||||
("calendar", "Календарь"),
|
||||
("feed", "Новости"),
|
||||
("other", "Остальные уведомления"),
|
||||
)
|
||||
CATEGORY_KEYS = tuple(key for key, _label in NOTIFICATION_CATEGORIES)
|
||||
|
||||
MESSAGE_VERBS = {
|
||||
"chat_added_to_chat",
|
||||
"chat_new_message",
|
||||
"chat_reply",
|
||||
"chat_mention",
|
||||
"announcement_new_message",
|
||||
}
|
||||
|
||||
PREFIX_CATEGORIES = (
|
||||
(("task_", "board_"), "tasks"),
|
||||
(("request_",), "requests"),
|
||||
(("procurement_",), "procurement"),
|
||||
(("document_", "regulation_"), "documents"),
|
||||
(("guest_",), "guests"),
|
||||
(("event_", "calendar_"), "calendar"),
|
||||
(("feed_",), "feed"),
|
||||
)
|
||||
|
||||
|
||||
def notification_category(verb: str) -> str:
|
||||
if verb in MESSAGE_VERBS or verb.startswith("chat_"):
|
||||
return "messages"
|
||||
for prefixes, category in PREFIX_CATEGORIES:
|
||||
if verb.startswith(prefixes):
|
||||
return category
|
||||
return "other"
|
||||
|
||||
|
||||
def default_routes() -> dict[str, list[str]]:
|
||||
return {key: [APP_MAIN] for key in CATEGORY_KEYS}
|
||||
|
||||
|
||||
def normalize_destinations(destinations: object) -> list[str]:
|
||||
if not isinstance(destinations, Iterable) or isinstance(
|
||||
destinations,
|
||||
(str, bytes),
|
||||
):
|
||||
return [APP_MAIN]
|
||||
normalized = []
|
||||
for destination in destinations:
|
||||
if destination in VALID_APPS and destination not in normalized:
|
||||
normalized.append(destination)
|
||||
return normalized or [APP_MAIN]
|
||||
|
||||
|
||||
def normalize_routes(routes: object) -> dict[str, list[str]]:
|
||||
source = routes if isinstance(routes, dict) else {}
|
||||
return {
|
||||
key: normalize_destinations(source.get(key, [APP_MAIN]))
|
||||
for key in CATEGORY_KEYS
|
||||
}
|
||||
@ -3,12 +3,16 @@ Web Push отправитель для browser push notifications
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from push_notifications.models import WebPushDevice
|
||||
|
||||
from .base import BaseNotificationSender
|
||||
from ..config import get
|
||||
from ..models import NotificationDeliveryRouting, NotificationInstallation
|
||||
from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
|
||||
|
||||
|
||||
class PushNotificationSender(BaseNotificationSender):
|
||||
@ -107,12 +111,89 @@ class PushNotificationSender(BaseNotificationSender):
|
||||
return
|
||||
device.active = False
|
||||
device.save(update_fields=["active"])
|
||||
NotificationInstallation.objects.filter(
|
||||
push_device_id=device.id,
|
||||
).update(endpoint=None, push_device_id=None)
|
||||
self.logger.info(
|
||||
"Push device %s deactivated after permanent delivery error: %s",
|
||||
device.id,
|
||||
reason,
|
||||
)
|
||||
|
||||
def _delivery_devices(self, notification):
|
||||
user = notification.recipient
|
||||
active_devices = list(
|
||||
WebPushDevice.objects.filter(user=user, active=True)
|
||||
)
|
||||
if not active_devices:
|
||||
return [], {}
|
||||
|
||||
devices_by_endpoint = {
|
||||
device.registration_id: device for device in active_devices
|
||||
}
|
||||
installations = list(
|
||||
NotificationInstallation.objects.filter(
|
||||
user=user,
|
||||
active=True,
|
||||
endpoint__in=devices_by_endpoint,
|
||||
)
|
||||
)
|
||||
represented_endpoints = {
|
||||
installation.endpoint for installation in installations
|
||||
}
|
||||
selected = [
|
||||
device
|
||||
for device in active_devices
|
||||
if device.registration_id not in represented_endpoints
|
||||
]
|
||||
fallback_by_device_id = {}
|
||||
category = notification_category(notification.verb)
|
||||
routing_by_group = {
|
||||
str(routing.device_group_id): normalize_routes(routing.routes)
|
||||
for routing in NotificationDeliveryRouting.objects.filter(
|
||||
user=user,
|
||||
device_group_id__in={
|
||||
installation.device_group_id
|
||||
for installation in installations
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
grouped = {}
|
||||
messenger_cutoff = timezone.now() - timedelta(
|
||||
days=getattr(settings, "MESSENGER_PUSH_LEASE_DAYS", 30),
|
||||
)
|
||||
for installation in installations:
|
||||
if (
|
||||
installation.app_kind == APP_MESSENGER
|
||||
and installation.last_seen_at < messenger_cutoff
|
||||
):
|
||||
continue
|
||||
grouped.setdefault(str(installation.device_group_id), {}).setdefault(
|
||||
installation.app_kind,
|
||||
[],
|
||||
).append(devices_by_endpoint[installation.endpoint])
|
||||
|
||||
for group_id, app_devices in grouped.items():
|
||||
routes = routing_by_group.get(group_id, normalize_routes({}))
|
||||
destinations = routes[category]
|
||||
main_devices = app_devices.get(APP_MAIN, [])
|
||||
messenger_devices = app_devices.get(APP_MESSENGER, [])
|
||||
|
||||
if APP_MAIN in destinations:
|
||||
selected.extend(main_devices)
|
||||
if APP_MESSENGER in destinations:
|
||||
if messenger_devices:
|
||||
selected.extend(messenger_devices)
|
||||
if APP_MAIN not in destinations and main_devices:
|
||||
for messenger_device in messenger_devices:
|
||||
fallback_by_device_id[messenger_device.id] = main_devices
|
||||
elif APP_MAIN not in destinations:
|
||||
selected.extend(main_devices)
|
||||
|
||||
unique_selected = {device.id: device for device in selected}
|
||||
return list(unique_selected.values()), fallback_by_device_id
|
||||
|
||||
def send(self, notification, **kwargs) -> bool:
|
||||
"""
|
||||
Отправляет Web Push уведомление.
|
||||
@ -125,12 +206,9 @@ class PushNotificationSender(BaseNotificationSender):
|
||||
True если отправлено успешно, False иначе
|
||||
"""
|
||||
try:
|
||||
user = notification.recipient
|
||||
devices, fallback_by_device_id = self._delivery_devices(notification)
|
||||
|
||||
# Получаем активные устройства через django-push-notifications
|
||||
devices = WebPushDevice.objects.filter(user=user, active=True)
|
||||
|
||||
if not devices.exists():
|
||||
if not devices:
|
||||
self.log_skip(notification, "no active push devices")
|
||||
return False
|
||||
|
||||
@ -185,15 +263,20 @@ class PushNotificationSender(BaseNotificationSender):
|
||||
failed_count = 0
|
||||
|
||||
# Отправляем через все устройства пользователя
|
||||
for device in devices:
|
||||
sent_device_ids = set()
|
||||
|
||||
def send_to_device(device):
|
||||
nonlocal sent_count, failed_count
|
||||
try:
|
||||
device.send_message(message)
|
||||
sent_count += 1
|
||||
sent_device_ids.add(device.id)
|
||||
self.logger.debug(
|
||||
f"Push отправлен на device {device.id} ({
|
||||
device.browser
|
||||
})"
|
||||
"Push отправлен на device %s (%s)",
|
||||
device.id,
|
||||
device.browser,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
# Вычисляем размер payload для диагностики
|
||||
@ -205,6 +288,17 @@ class PushNotificationSender(BaseNotificationSender):
|
||||
)
|
||||
if self._is_permanent_device_error(e):
|
||||
self._deactivate_device(device, error_text)
|
||||
return False
|
||||
|
||||
for device in devices:
|
||||
delivered = send_to_device(device)
|
||||
if delivered:
|
||||
continue
|
||||
for fallback_device in fallback_by_device_id.get(device.id, []):
|
||||
if fallback_device.id in sent_device_ids:
|
||||
continue
|
||||
if send_to_device(fallback_device):
|
||||
break
|
||||
|
||||
if sent_count > 0:
|
||||
self.log_success(notification, f"{sent_count} devices")
|
||||
|
||||
@ -5,8 +5,12 @@ from django.core.cache import cache
|
||||
|
||||
from push_notifications.models import WebPushDevice
|
||||
from notifications.cache import unread_summary_cache_key
|
||||
from notifications.models import Notification
|
||||
from notifications.models import UserChannelPreferences
|
||||
from notifications.models import (
|
||||
Notification,
|
||||
NotificationDeliveryRouting,
|
||||
NotificationInstallation,
|
||||
UserChannelPreferences,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@ -350,3 +354,132 @@ class TestNotificationPreferencesApi:
|
||||
assert response.status_code == 200
|
||||
prefs = UserChannelPreferences.objects.get(user=user)
|
||||
assert prefs.push_enabled is False
|
||||
|
||||
def test_subscribe_push_registers_application_installation(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/v1/notifications/push/subscribe/",
|
||||
{
|
||||
"endpoint": "https://push.example.test/messenger",
|
||||
"keys": {"p256dh": "test-p256dh", "auth": "test-auth"},
|
||||
"device_name": "Safari iPhone",
|
||||
"installation_id": "79307716-4220-48c4-9357-57294dfb24f3",
|
||||
"device_group_id": "084127c8-ac7a-47c8-92c5-d7d41713b150",
|
||||
"app_kind": "messenger",
|
||||
"installed": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
installation = NotificationInstallation.objects.get(user=user)
|
||||
assert installation.app_kind == "messenger"
|
||||
assert installation.installed is True
|
||||
assert installation.push_ready is True
|
||||
|
||||
def test_delivery_routing_requires_ready_messenger(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
device_group_id = "73d2d87f-2cd7-44ca-a658-d7fd19a85898"
|
||||
|
||||
response = api_client.put(
|
||||
"/api/v1/notifications/push/delivery-routing/",
|
||||
{
|
||||
"device_group_id": device_group_id,
|
||||
"routes": {"messages": ["messenger"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_delivery_routing_can_select_both_apps(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
device_group_id = "beb3746c-0af9-4b60-a9c2-42747c08a87f"
|
||||
device = WebPushDevice.objects.create(
|
||||
user=user,
|
||||
registration_id="https://push.example.test/messenger-ready",
|
||||
p256dh="test-p256dh",
|
||||
auth="test-auth",
|
||||
browser="Safari",
|
||||
active=True,
|
||||
)
|
||||
NotificationInstallation.objects.create(
|
||||
user=user,
|
||||
installation_id="bda0fdc5-1f0c-4d13-a9e2-e143eb9a44db",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="messenger",
|
||||
endpoint=device.registration_id,
|
||||
push_device_id=device.id,
|
||||
installed=True,
|
||||
)
|
||||
|
||||
response = api_client.put(
|
||||
"/api/v1/notifications/push/delivery-routing/",
|
||||
{
|
||||
"device_group_id": device_group_id,
|
||||
"routes": {
|
||||
"messages": ["main", "messenger"],
|
||||
"tasks": ["main"],
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
routing = NotificationDeliveryRouting.objects.get(user=user)
|
||||
assert routing.routes["messages"] == ["main", "messenger"]
|
||||
assert response.data["routes"]["tasks"] == ["main"]
|
||||
assert response.data["routes"]["requests"] == ["main"]
|
||||
|
||||
def test_disconnect_messenger_restores_main_routes(
|
||||
self, api_client, user_factory
|
||||
):
|
||||
user = user_factory()
|
||||
api_client.force_authenticate(user=user)
|
||||
device_group_id = "174b2b10-e7b9-465c-b0a1-05f0db81b256"
|
||||
device = WebPushDevice.objects.create(
|
||||
user=user,
|
||||
registration_id="https://push.example.test/disconnect-messenger",
|
||||
p256dh="test-p256dh",
|
||||
auth="test-auth",
|
||||
browser="Chrome",
|
||||
active=True,
|
||||
)
|
||||
installation = NotificationInstallation.objects.create(
|
||||
user=user,
|
||||
installation_id="4971e5b3-adf8-4dbd-ae91-755d28bd5d44",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="messenger",
|
||||
endpoint=device.registration_id,
|
||||
push_device_id=device.id,
|
||||
installed=True,
|
||||
)
|
||||
NotificationDeliveryRouting.objects.create(
|
||||
user=user,
|
||||
device_group_id=device_group_id,
|
||||
routes={"messages": ["messenger"], "tasks": ["main", "messenger"]},
|
||||
)
|
||||
|
||||
response = api_client.delete(
|
||||
"/api/v1/notifications/push/installations/disconnect/",
|
||||
{"device_group_id": device_group_id, "app_kind": "messenger"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["routes"]["messages"] == ["main"]
|
||||
assert response.data["routes"]["tasks"] == ["main"]
|
||||
installation.refresh_from_db()
|
||||
assert installation.installed is False
|
||||
assert installation.endpoint is None
|
||||
assert not WebPushDevice.objects.filter(pk=device.pk).exists()
|
||||
|
||||
@ -1,22 +1,29 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from push_notifications.models import WebPushDevice
|
||||
|
||||
from notifications.models import Notification, UserChannelPreferences
|
||||
from notifications.models import (
|
||||
Notification,
|
||||
NotificationDeliveryRouting,
|
||||
NotificationInstallation,
|
||||
UserChannelPreferences,
|
||||
)
|
||||
from notifications.senders.push import PushNotificationSender
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _make_push_device(user):
|
||||
def _make_push_device(user, suffix=""):
|
||||
return WebPushDevice.objects.create(
|
||||
user=user,
|
||||
registration_id="https://push.example.test/endpoint",
|
||||
registration_id=f"https://push.example.test/endpoint{suffix}",
|
||||
p256dh="test-p256dh",
|
||||
auth="test-auth",
|
||||
browser="CHROME",
|
||||
@ -244,3 +251,199 @@ class TestPushNotificationSenderPayload:
|
||||
assert result is False
|
||||
device.refresh_from_db()
|
||||
assert device.active is True
|
||||
|
||||
def test_message_routes_to_messenger_only(self, user_factory):
|
||||
recipient = user_factory(email="routing-recipient@example.com")
|
||||
main_device = _make_push_device(recipient, "-main")
|
||||
messenger_device = _make_push_device(recipient, "-messenger")
|
||||
device_group_id = "ad15799f-f493-4551-aa65-962486211bcc"
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id="5fe41a07-fdb0-4157-8170-ed6333a2b2ac",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="main",
|
||||
endpoint=main_device.registration_id,
|
||||
push_device_id=main_device.id,
|
||||
installed=True,
|
||||
)
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id="26a8a013-9338-4a4f-ac14-db4092cb46e7",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="messenger",
|
||||
endpoint=messenger_device.registration_id,
|
||||
push_device_id=messenger_device.id,
|
||||
installed=True,
|
||||
)
|
||||
NotificationDeliveryRouting.objects.create(
|
||||
user=recipient,
|
||||
device_group_id=device_group_id,
|
||||
routes={"messages": ["messenger"]},
|
||||
)
|
||||
notification = _make_notification(
|
||||
recipient,
|
||||
verb="chat_new_message",
|
||||
description="Сообщение",
|
||||
action_url="/messages/1",
|
||||
)
|
||||
|
||||
sender = PushNotificationSender()
|
||||
with patch.object(
|
||||
WebPushDevice,
|
||||
"send_message",
|
||||
autospec=True,
|
||||
) as send_message:
|
||||
assert sender.send(notification) is True
|
||||
|
||||
assert send_message.call_count == 1
|
||||
assert send_message.call_args.args[0].id == messenger_device.id
|
||||
|
||||
def test_messenger_failure_falls_back_to_main(self, user_factory):
|
||||
recipient = user_factory(email="fallback-routing@example.com")
|
||||
main_device = _make_push_device(recipient, "-main")
|
||||
messenger_device = _make_push_device(recipient, "-messenger")
|
||||
device_group_id = "f5a714b9-7066-4d56-bbca-86ca4fe7fe72"
|
||||
for app_kind, installation_id, device in (
|
||||
(
|
||||
"main",
|
||||
"68ddb9b8-2001-4ede-89d9-3f46efbb3c42",
|
||||
main_device,
|
||||
),
|
||||
(
|
||||
"messenger",
|
||||
"397b4a43-85a7-4b78-a9f0-b051327da8f9",
|
||||
messenger_device,
|
||||
),
|
||||
):
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id=installation_id,
|
||||
device_group_id=device_group_id,
|
||||
app_kind=app_kind,
|
||||
endpoint=device.registration_id,
|
||||
push_device_id=device.id,
|
||||
installed=True,
|
||||
)
|
||||
NotificationDeliveryRouting.objects.create(
|
||||
user=recipient,
|
||||
device_group_id=device_group_id,
|
||||
routes={"messages": ["messenger"]},
|
||||
)
|
||||
notification = _make_notification(
|
||||
recipient,
|
||||
verb="chat_new_message",
|
||||
description="Сообщение",
|
||||
action_url="/messages/1",
|
||||
)
|
||||
|
||||
def send(device, _message):
|
||||
if device.id == messenger_device.id:
|
||||
raise Exception("410 Gone")
|
||||
|
||||
sender = PushNotificationSender()
|
||||
with patch.object(
|
||||
WebPushDevice,
|
||||
"send_message",
|
||||
autospec=True,
|
||||
side_effect=send,
|
||||
) as send_message:
|
||||
assert sender.send(notification) is True
|
||||
|
||||
assert [call.args[0].id for call in send_message.call_args_list] == [
|
||||
messenger_device.id,
|
||||
main_device.id,
|
||||
]
|
||||
messenger_device.refresh_from_db()
|
||||
assert messenger_device.active is False
|
||||
|
||||
def test_non_message_keeps_default_main_route(self, user_factory):
|
||||
recipient = user_factory(email="default-routing@example.com")
|
||||
main_device = _make_push_device(recipient, "-main")
|
||||
messenger_device = _make_push_device(recipient, "-messenger")
|
||||
device_group_id = "65664580-0840-4a30-9446-1887999acd4d"
|
||||
for app_kind, installation_id, device in (
|
||||
(
|
||||
"main",
|
||||
"63661c89-58f6-4d1a-a51d-df85aac22273",
|
||||
main_device,
|
||||
),
|
||||
(
|
||||
"messenger",
|
||||
"d7b7e05f-a48d-4af0-bbd8-0bcd70b7970e",
|
||||
messenger_device,
|
||||
),
|
||||
):
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id=installation_id,
|
||||
device_group_id=device_group_id,
|
||||
app_kind=app_kind,
|
||||
endpoint=device.registration_id,
|
||||
push_device_id=device.id,
|
||||
installed=True,
|
||||
)
|
||||
notification = _make_notification(
|
||||
recipient,
|
||||
verb="request_approved",
|
||||
description="Заявление одобрено",
|
||||
action_url="/requests",
|
||||
)
|
||||
|
||||
sender = PushNotificationSender()
|
||||
with patch.object(
|
||||
WebPushDevice,
|
||||
"send_message",
|
||||
autospec=True,
|
||||
) as send_message:
|
||||
assert sender.send(notification) is True
|
||||
|
||||
assert send_message.call_count == 1
|
||||
assert send_message.call_args.args[0].id == main_device.id
|
||||
|
||||
@override_settings(MESSENGER_PUSH_LEASE_DAYS=30)
|
||||
def test_stale_messenger_route_falls_back_to_main(self, user_factory):
|
||||
recipient = user_factory(email="stale-routing@example.com")
|
||||
main_device = _make_push_device(recipient, "-main")
|
||||
messenger_device = _make_push_device(recipient, "-messenger")
|
||||
device_group_id = "f699909c-2a64-4240-9db4-62c068223f2c"
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id="df61bef7-8eb1-46c7-890a-a3828778e5ad",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="main",
|
||||
endpoint=main_device.registration_id,
|
||||
push_device_id=main_device.id,
|
||||
installed=True,
|
||||
)
|
||||
NotificationInstallation.objects.create(
|
||||
user=recipient,
|
||||
installation_id="fe5fd3fd-1ab2-47a3-8e38-37e3ec5d8327",
|
||||
device_group_id=device_group_id,
|
||||
app_kind="messenger",
|
||||
endpoint=messenger_device.registration_id,
|
||||
push_device_id=messenger_device.id,
|
||||
installed=True,
|
||||
last_seen_at=timezone.now() - timedelta(days=31),
|
||||
)
|
||||
NotificationDeliveryRouting.objects.create(
|
||||
user=recipient,
|
||||
device_group_id=device_group_id,
|
||||
routes={"messages": ["messenger"]},
|
||||
)
|
||||
notification = _make_notification(
|
||||
recipient,
|
||||
verb="chat_new_message",
|
||||
description="Сообщение",
|
||||
action_url="/messages/1",
|
||||
)
|
||||
|
||||
sender = PushNotificationSender()
|
||||
with patch.object(
|
||||
WebPushDevice,
|
||||
"send_message",
|
||||
autospec=True,
|
||||
) as send_message:
|
||||
assert sender.send(notification) is True
|
||||
|
||||
assert send_message.call_count == 1
|
||||
assert send_message.call_args.args[0].id == main_device.id
|
||||
|
||||
BIN
frontend/public/messenger-icon-180.png
Normal file
BIN
frontend/public/messenger-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/messenger-icon-192.png
Normal file
BIN
frontend/public/messenger-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/messenger-icon-512-maskable.png
Normal file
BIN
frontend/public/messenger-icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/messenger-icon-512.png
Normal file
BIN
frontend/public/messenger-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
11
frontend/public/messenger-icon-source.svg
Normal file
11
frontend/public/messenger-icon-source.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#111923"/>
|
||||
<path
|
||||
d="M416 336c0 17.7-14.3 32-32 32H173.2c-8.5 0-16.6 3.4-22.6 9.4l-35.2 35.2c-7.2 7.2-19.4 2.1-19.4-8V144c0-17.7 14.3-32 32-32h256c17.7 0 32 14.3 32 32Z"
|
||||
fill="none"
|
||||
stroke="#27b7f6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="30"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
4
frontend/public/messenger-sw.js
Normal file
4
frontend/public/messenger-sw.js
Normal file
@ -0,0 +1,4 @@
|
||||
// The messenger has an independent registration and PushSubscription while
|
||||
// reusing the same cache and notification behavior as the main application.
|
||||
self.CORP_SW_APP_KIND = "messenger";
|
||||
importScripts("/sw.js");
|
||||
@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
const SW_VERSION = '2.1.2';
|
||||
const SW_APP_KIND = self.CORP_SW_APP_KIND || 'main';
|
||||
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
|
||||
const PRECACHE_URLS = [
|
||||
'/manifest.webmanifest',
|
||||
@ -163,7 +164,7 @@ self.addEventListener('push', (event) => {
|
||||
notificationData.requireInteraction = payload.requireInteraction;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
} catch {
|
||||
console.log('[SW] Push data is not JSON, using as text');
|
||||
notificationData.body = event.data.text();
|
||||
}
|
||||
@ -207,9 +208,11 @@ self.addEventListener('notificationclick', (event) => {
|
||||
return exactClient.focus();
|
||||
}
|
||||
|
||||
const sameOriginClients = windowClients.filter(
|
||||
(client) => new URL(client.url).origin === self.location.origin,
|
||||
);
|
||||
const sameOriginClients = windowClients.filter((client) => {
|
||||
const clientUrl = new URL(client.url);
|
||||
if (clientUrl.origin !== self.location.origin) return false;
|
||||
return SW_APP_KIND !== 'messenger' || clientUrl.pathname.startsWith('/messages');
|
||||
});
|
||||
const sameOriginClient =
|
||||
sameOriginClients.find((client) => client.focused) ||
|
||||
sameOriginClients.find((client) => client.visibilityState === 'visible') ||
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "CORP - HiRo",
|
||||
short_name: "CORP - HiRo",
|
||||
description: "Корпоративная система управления CORP - HiRo",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#111923",
|
||||
theme_color: "#111923",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512-maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
41
frontend/src/app/manifest.webmanifest/route.ts
Normal file
41
frontend/src/app/manifest.webmanifest/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: "/",
|
||||
name: "CORP - HiRo",
|
||||
short_name: "CORP - HiRo",
|
||||
description: "Корпоративная система управления CORP - HiRo",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#111923",
|
||||
theme_color: "#111923",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512-maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/manifest+json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import MessagesWorkspaceShell from "@/components/messages/MessagesWorkspaceShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CORP Мессенджер",
|
||||
applicationName: "CORP Мессенджер",
|
||||
manifest: "/messenger.webmanifest",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/messenger-icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ url: "/messenger-icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: "/messenger-icon-180.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "CORP Мессенджер",
|
||||
},
|
||||
};
|
||||
|
||||
export default function MessagesLayout({ children }: { children: ReactNode }) {
|
||||
return <MessagesWorkspaceShell>{children}</MessagesWorkspaceShell>;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { MessageCircle, Plus } from "lucide-react";
|
||||
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
|
||||
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
|
||||
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
|
||||
import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton";
|
||||
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
|
||||
import { useMessagesWorkspace } from "@/components/messages/MessagesWorkspaceContext";
|
||||
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
|
||||
|
||||
@ -58,7 +58,7 @@ function StandardMessagesList() {
|
||||
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Сообщения</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<MessagesFocusModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
|
||||
<MessagesImmersiveModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "immersive" : "standard")} />
|
||||
<MessagesInstallButton />
|
||||
<button type="button" onClick={openCreateChat} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
|
||||
<Plus size={14} /> Создать чат
|
||||
</button>
|
||||
|
||||
41
frontend/src/app/messenger.webmanifest/route.ts
Normal file
41
frontend/src/app/messenger.webmanifest/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: "/messenger",
|
||||
name: "CORP Мессенджер",
|
||||
short_name: "Мессенджер",
|
||||
description: "Сообщения CORP - HiRo",
|
||||
start_url: "/messages?pwa=messenger",
|
||||
scope: "/messages",
|
||||
display: "standalone",
|
||||
background_color: "#111923",
|
||||
theme_color: "#111923",
|
||||
icons: [
|
||||
{
|
||||
src: "/messenger-icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/messenger-icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/messenger-icon-512-maskable.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/manifest+json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import NotificationDeliveryApps from "@/components/settings/NotificationDeliveryApps";
|
||||
import { usePwa } from "@/contexts/PwaContext";
|
||||
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
|
||||
import {
|
||||
@ -664,6 +665,8 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationDeliveryApps />
|
||||
|
||||
<div className="app-surface-muted rounded-2xl p-5">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
|
||||
@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
|
||||
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
|
||||
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
|
||||
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
|
||||
import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton";
|
||||
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
|
||||
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
|
||||
import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
|
||||
|
||||
@ -33,10 +33,7 @@ export default function MessagesFocusSidebar({
|
||||
enabled={mode !== "standard"}
|
||||
onChange={(enabled) => onModeChange(enabled ? "wide" : "standard")}
|
||||
/>
|
||||
<MessagesImmersiveModeButton
|
||||
enabled={mode === "immersive"}
|
||||
onChange={(enabled) => onModeChange(enabled ? "immersive" : "wide")}
|
||||
/>
|
||||
<MessagesInstallButton />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateChat}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PanelTopClose, PanelTopOpen } from "lucide-react";
|
||||
|
||||
type MessagesImmersiveModeButtonProps = {
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function MessagesImmersiveModeButton({
|
||||
enabled,
|
||||
onChange,
|
||||
}: MessagesImmersiveModeButtonProps) {
|
||||
const label = enabled ? "Выйти из режима мессенджера" : "Открыть режим мессенджера";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!enabled)}
|
||||
className="app-action-ghost hidden h-9 w-9 shrink-0 items-center justify-center rounded-md lg:inline-flex"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={enabled}
|
||||
>
|
||||
{enabled ? <PanelTopOpen size={17} /> : <PanelTopClose size={17} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
266
frontend/src/components/messages/MessagesInstallButton.tsx
Normal file
266
frontend/src/components/messages/MessagesInstallButton.tsx
Normal file
@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Download, Monitor, Share2, Smartphone } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { usePwa } from "@/contexts/PwaContext";
|
||||
import { useWebPush } from "@/hooks/useWebPush";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
clearPendingMessengerRoute,
|
||||
getDeviceGroupId,
|
||||
getPendingMessengerRoute,
|
||||
isMessengerLaunch,
|
||||
savePendingMessengerRoute,
|
||||
type NotificationDestination,
|
||||
} from "@/lib/notifications/installations";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type DeliveryChoice = "messenger" | "both" | "main";
|
||||
|
||||
const CHOICES: Array<{
|
||||
value: DeliveryChoice;
|
||||
title: string;
|
||||
description: string;
|
||||
destinations: NotificationDestination[];
|
||||
}> = [
|
||||
{
|
||||
value: "messenger",
|
||||
title: "Только мессенджер",
|
||||
description: "Без дубликатов в основном приложении на этом устройстве.",
|
||||
destinations: ["messenger"],
|
||||
},
|
||||
{
|
||||
value: "both",
|
||||
title: "Оба приложения",
|
||||
description: "Сообщения будут приходить в CORP и мессенджер.",
|
||||
destinations: ["main", "messenger"],
|
||||
},
|
||||
{
|
||||
value: "main",
|
||||
title: "Только CORP",
|
||||
description: "Мессенджер установится без push о сообщениях.",
|
||||
destinations: ["main"],
|
||||
},
|
||||
];
|
||||
|
||||
function isIosDevice() {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
export default function MessagesInstallButton() {
|
||||
const {
|
||||
canInstallMessenger,
|
||||
installMessenger,
|
||||
} = usePwa();
|
||||
const messengerLaunch = useMemo(() => isMessengerLaunch(), []);
|
||||
const pendingRoute = useMemo(() => getPendingMessengerRoute(), []);
|
||||
const [open, setOpen] = useState(Boolean(messengerLaunch && pendingRoute));
|
||||
const [choice, setChoice] = useState<DeliveryChoice>("messenger");
|
||||
const [showIosInstructions, setShowIosInstructions] = useState(false);
|
||||
const [finishing, setFinishing] = useState(false);
|
||||
const [messengerConnected, setMessengerConnected] = useState(false);
|
||||
const messengerPush = useWebPush({
|
||||
appKind: "messenger",
|
||||
enabled: messengerLaunch || open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (messengerLaunch && getPendingMessengerRoute()) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [messengerLaunch]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void apiClient.getNotificationDeliverySettings(getDeviceGroupId())
|
||||
.then((settings) => {
|
||||
if (!mounted) return;
|
||||
setMessengerConnected(settings.installations.some(
|
||||
(installation) => installation.app_kind === "messenger" && installation.installed && installation.push_ready,
|
||||
));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finishSetup = async (destinations: NotificationDestination[]) => {
|
||||
setFinishing(true);
|
||||
try {
|
||||
const subscribed = messengerPush.isSubscribed || await messengerPush.subscribe();
|
||||
if (!subscribed) return;
|
||||
|
||||
const deviceGroupId = getDeviceGroupId();
|
||||
const current = await apiClient.getNotificationDeliverySettings(deviceGroupId);
|
||||
await apiClient.updateNotificationDeliverySettings({
|
||||
device_group_id: deviceGroupId,
|
||||
routes: {
|
||||
...current.routes,
|
||||
messages: destinations,
|
||||
},
|
||||
});
|
||||
clearPendingMessengerRoute();
|
||||
setMessengerConnected(true);
|
||||
setOpen(false);
|
||||
toast.success("Мессенджер подключен к уведомлениям");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Не удалось завершить настройку уведомлений");
|
||||
} finally {
|
||||
setFinishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startInstall = async () => {
|
||||
const destinations = CHOICES.find((item) => item.value === choice)?.destinations || ["messenger"];
|
||||
savePendingMessengerRoute(destinations);
|
||||
|
||||
const result = await installMessenger();
|
||||
if (result === "accepted") {
|
||||
setOpen(false);
|
||||
toast.success("Откройте установленный мессенджер, чтобы включить уведомления");
|
||||
return;
|
||||
}
|
||||
if (result === "dismissed") {
|
||||
toast.info("Установка отменена. Настройки CORP не изменены");
|
||||
return;
|
||||
}
|
||||
setShowIosInstructions(true);
|
||||
};
|
||||
|
||||
if (!messengerLaunch && (messengerConnected || !canInstallMessenger)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="app-action-ghost inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
||||
title="Установить мессенджер"
|
||||
aria-label="Установить мессенджер"
|
||||
>
|
||||
<Download size={17} />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={messengerLaunch ? "Завершить настройку" : "Установить мессенджер"}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
{showIosInstructions ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="app-action-primary rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
Понятно
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const destinations = pendingRoute || CHOICES.find((item) => item.value === choice)?.destinations || ["messenger"];
|
||||
if (messengerLaunch) {
|
||||
void finishSetup(destinations);
|
||||
} else {
|
||||
void startInstall();
|
||||
}
|
||||
}}
|
||||
disabled={finishing || messengerPush.isLoading}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Download size={16} />
|
||||
{messengerLaunch ? "Включить уведомления" : "Продолжить"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{showIosInstructions ? (
|
||||
<div className="space-y-4 pb-5">
|
||||
<div className="app-selected rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Share2 size={20} className="app-accent-text mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{isIosDevice() ? "Установка на iPhone или iPad" : "Завершите установку мессенджера"}
|
||||
</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
{isIosDevice()
|
||||
? "Нажмите «Поделиться» в браузере, выберите «На экран Домой», затем откройте установленный мессенджер."
|
||||
: "Используйте команду установки в меню браузера, затем откройте CORP Мессенджер из списка приложений."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="app-text-muted text-sm">
|
||||
Настройки обычного CORP останутся активными, пока мессенджер не запустится и не получит разрешение на уведомления.
|
||||
</p>
|
||||
</div>
|
||||
) : messengerLaunch ? (
|
||||
<div className="space-y-4 pb-5">
|
||||
<div className="app-selected rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Smartphone size={20} className="app-accent-text mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Разрешите системные уведомления</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
После подтверждения выбранный маршрут сообщений начнет действовать на этом устройстве.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pb-5">
|
||||
<p className="app-text-muted text-sm">
|
||||
Выберите, где получать уведомления о сообщениях на этом устройстве.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{CHOICES.map((item) => {
|
||||
const selected = choice === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setChoice(item.value)}
|
||||
className={`flex w-full items-start gap-3 rounded-xl p-3 text-left transition ${selected ? "app-selected" : "app-surface-muted"}`}
|
||||
>
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${selected ? "app-badge app-badge-accent" : "app-badge"}`}>
|
||||
{selected ? <Check size={15} /> : item.value === "main" ? <Monitor size={15} /> : <Smartphone size={15} />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-[var(--foreground)]">{item.title}</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">{item.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isIosDevice() ? (
|
||||
<p className="app-text-muted text-xs">На iOS после выбора появится инструкция добавления на домашний экран.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
@ -10,7 +10,12 @@ import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar";
|
||||
import { MessagesWorkspaceContext } from "@/components/messages/MessagesWorkspaceContext";
|
||||
import { useUser } from "@/contexts/UserContext";
|
||||
import { useMessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
|
||||
import { getMessagesActiveChatId, isMessagesDialogPath } from "@/lib/messages/workspace.js";
|
||||
import {
|
||||
getMessagesActiveChatId,
|
||||
isMessagesDialogPath,
|
||||
resolveEffectiveMessagesWorkspaceMode,
|
||||
} from "@/lib/messages/workspace.js";
|
||||
import { isMessengerLaunch } from "@/lib/notifications/installations";
|
||||
|
||||
type MessagesWorkspaceShellProps = {
|
||||
children: ReactNode;
|
||||
@ -20,7 +25,12 @@ export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceSh
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const [createChatOpen, setCreateChatOpen] = useState(false);
|
||||
const [mode, setMode] = useMessagesWorkspaceMode(user?.id);
|
||||
const [storedMode, setStoredMode] = useMessagesWorkspaceMode(user?.id);
|
||||
const messengerLaunch = useMemo(() => isMessengerLaunch(), []);
|
||||
const mode = resolveEffectiveMessagesWorkspaceMode(storedMode, messengerLaunch);
|
||||
const setMode = useCallback((nextMode: typeof mode) => {
|
||||
if (!messengerLaunch) setStoredMode(nextMode);
|
||||
}, [messengerLaunch, setStoredMode]);
|
||||
const workspaceMode = mode !== "standard";
|
||||
const immersiveMode = mode === "immersive";
|
||||
const activeChatId = getMessagesActiveChatId(pathname);
|
||||
|
||||
233
frontend/src/components/settings/NotificationDeliveryApps.tsx
Normal file
233
frontend/src/components/settings/NotificationDeliveryApps.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Monitor, Save, Smartphone, Unplug } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
clearMessengerInstallMarker,
|
||||
getDeviceGroupId,
|
||||
type NotificationDeliverySettings,
|
||||
type NotificationDestination,
|
||||
} from "@/lib/notifications/installations";
|
||||
|
||||
function destinationEnabled(
|
||||
destinations: NotificationDestination[] | undefined,
|
||||
destination: NotificationDestination,
|
||||
) {
|
||||
return destinations?.includes(destination) ?? destination === "main";
|
||||
}
|
||||
|
||||
export default function NotificationDeliveryApps() {
|
||||
const [settings, setSettings] = useState<NotificationDeliverySettings | null>(null);
|
||||
const [savedRoutes, setSavedRoutes] = useState<Record<string, NotificationDestination[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void apiClient.getNotificationDeliverySettings(getDeviceGroupId())
|
||||
.then((response) => {
|
||||
if (!mounted) return;
|
||||
setSettings(response);
|
||||
setSavedRoutes(response.routes);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error("Не удалось загрузить приложения доставки");
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const messenger = settings?.installations.find(
|
||||
(installation) => installation.app_kind === "messenger",
|
||||
);
|
||||
const messengerReady = Boolean(messenger?.installed && messenger.push_ready);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(settings?.routes || {}) !== JSON.stringify(savedRoutes),
|
||||
[savedRoutes, settings?.routes],
|
||||
);
|
||||
|
||||
const toggleDestination = (
|
||||
category: string,
|
||||
destination: NotificationDestination,
|
||||
) => {
|
||||
setSettings((current) => {
|
||||
if (!current) return current;
|
||||
const existing = current.routes[category] || ["main"];
|
||||
const enabled = existing.includes(destination);
|
||||
const next = enabled
|
||||
? existing.filter((item) => item !== destination)
|
||||
: [...existing, destination];
|
||||
if (next.length === 0) {
|
||||
toast.info("Для категории должно остаться хотя бы одно приложение");
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
routes: { ...current.routes, [category]: next },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!settings || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await apiClient.updateNotificationDeliverySettings({
|
||||
device_group_id: settings.device_group_id,
|
||||
routes: settings.routes,
|
||||
});
|
||||
setSettings(response);
|
||||
setSavedRoutes(response.routes);
|
||||
toast.success("Приложения доставки обновлены");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : "Не удалось сохранить маршруты");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectMessenger = async () => {
|
||||
if (!settings || !window.confirm(
|
||||
"Отключить мессенджер на этом устройстве? Уведомления вернутся в основной CORP.",
|
||||
)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await apiClient.disconnectNotificationInstallation({
|
||||
device_group_id: settings.device_group_id,
|
||||
app_kind: "messenger",
|
||||
});
|
||||
setSettings((current) => current ? {
|
||||
...current,
|
||||
routes: response.routes,
|
||||
installations: current.installations.filter(
|
||||
(installation) => installation.app_kind !== "messenger",
|
||||
),
|
||||
} : current);
|
||||
setSavedRoutes(response.routes);
|
||||
clearMessengerInstallMarker();
|
||||
toast.success("Мессенджер отключен, доставка возвращена в CORP");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Не удалось отключить мессенджер");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-surface-muted rounded-2xl p-5">
|
||||
<p className="app-text-muted text-sm">Загрузка приложений доставки...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<div className="app-surface-muted rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Приложения доставки</p>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Настройки относятся к текущему устройству. Тип события можно доставлять в одно или оба приложения.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!dirty || saving}
|
||||
className="app-action-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Save size={15} />
|
||||
{saving ? "Сохраняем..." : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-2 sm:grid-cols-2">
|
||||
<div className="app-surface rounded-xl p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="app-badge app-badge-accent flex h-9 w-9 items-center justify-center rounded-xl">
|
||||
<Monitor size={17} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Основной CORP</p>
|
||||
<p className="app-text-muted text-xs">Подключен на этом устройстве</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-surface rounded-xl p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`app-badge flex h-9 w-9 items-center justify-center rounded-xl ${messengerReady ? "app-badge-accent" : ""}`}>
|
||||
<Smartphone size={17} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">CORP Мессенджер</p>
|
||||
<p className="app-text-muted text-xs">
|
||||
{messengerReady ? "Установлен, push подключен" : "Не подключен на этом устройстве"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!messengerReady ? (
|
||||
<Link href="/messages" className="app-accent-text mt-2 inline-flex text-xs font-medium">
|
||||
Перейти к установке
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void disconnectMessenger()}
|
||||
className="app-action-ghost mt-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<Unplug size={13} />
|
||||
Отключить на устройстве
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[34rem] space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_9rem_9rem] gap-2 px-3 text-xs font-semibold uppercase text-[var(--muted-foreground)]">
|
||||
<span>Тип уведомлений</span>
|
||||
<span className="text-center">CORP</span>
|
||||
<span className="text-center">Мессенджер</span>
|
||||
</div>
|
||||
{settings.categories.map((category) => (
|
||||
<div
|
||||
key={category.key}
|
||||
className="app-surface grid grid-cols-[minmax(0,1fr)_9rem_9rem] items-center gap-2 rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<span className="min-w-0 text-sm font-medium text-[var(--foreground)]">{category.label}</span>
|
||||
{(["main", "messenger"] as const).map((destination) => {
|
||||
const enabled = destinationEnabled(settings.routes[category.key], destination);
|
||||
const disabled = destination === "messenger" && !messengerReady;
|
||||
return (
|
||||
<button
|
||||
key={destination}
|
||||
type="button"
|
||||
onClick={() => toggleDestination(category.key, destination)}
|
||||
disabled={disabled}
|
||||
className={`mx-auto flex h-8 w-8 items-center justify-center rounded-full transition ${enabled ? "app-badge app-badge-accent" : "app-badge"} disabled:cursor-not-allowed disabled:opacity-35`}
|
||||
aria-label={`${enabled ? "Отключить" : "Включить"} ${destination === "main" ? "CORP" : "мессенджер"} для ${category.label}`}
|
||||
aria-pressed={enabled}
|
||||
>
|
||||
{enabled ? <Check size={15} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
type BeforeInstallPromptEvent,
|
||||
isStandaloneDisplayMode,
|
||||
registerAppServiceWorker,
|
||||
} from "@/lib/pwa";
|
||||
import {
|
||||
hasMessengerInstallMarker,
|
||||
isMessengerLaunch,
|
||||
markMessengerInstalled,
|
||||
} from "@/lib/notifications/installations";
|
||||
|
||||
type InstallResult = "accepted" | "dismissed" | "unavailable";
|
||||
|
||||
@ -15,13 +21,29 @@ type PwaContextValue = {
|
||||
isInstalled: boolean;
|
||||
isRegistrationReady: boolean;
|
||||
install: () => Promise<InstallResult>;
|
||||
canInstallMessenger: boolean;
|
||||
isMessengerInstalled: boolean;
|
||||
installMessenger: () => Promise<InstallResult>;
|
||||
};
|
||||
|
||||
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
|
||||
|
||||
function supportsManualIosInstall() {
|
||||
if (typeof navigator === "undefined" || isStandaloneDisplayMode()) return false;
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
export function PwaProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode());
|
||||
const [messengerPrompt, setMessengerPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(
|
||||
() => isStandaloneDisplayMode() && !isMessengerLaunch(),
|
||||
);
|
||||
const [isMessengerInstalled, setIsMessengerInstalled] = useState(
|
||||
() => isMessengerLaunch() || hasMessengerInstallMarker(),
|
||||
);
|
||||
const [isRegistrationReady, setIsRegistrationReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -61,12 +83,22 @@ export function PwaProvider({ children }: { children: ReactNode }) {
|
||||
const handleBeforeInstallPrompt = (event: Event) => {
|
||||
const installEvent = event as BeforeInstallPromptEvent;
|
||||
installEvent.preventDefault();
|
||||
setDeferredPrompt(installEvent);
|
||||
if (pathname.startsWith("/messages")) {
|
||||
setMessengerPrompt(installEvent);
|
||||
} else {
|
||||
setDeferredPrompt(installEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setDeferredPrompt(null);
|
||||
setIsInstalled(true);
|
||||
if (pathname.startsWith("/messages")) {
|
||||
setMessengerPrompt(null);
|
||||
setIsMessengerInstalled(true);
|
||||
markMessengerInstalled();
|
||||
} else {
|
||||
setDeferredPrompt(null);
|
||||
setIsInstalled(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
@ -76,7 +108,7 @@ export function PwaProvider({ children }: { children: ReactNode }) {
|
||||
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", handleAppInstalled);
|
||||
};
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
const install = useCallback(async (): Promise<InstallResult> => {
|
||||
if (!deferredPrompt) {
|
||||
@ -95,14 +127,41 @@ export function PwaProvider({ children }: { children: ReactNode }) {
|
||||
return "dismissed";
|
||||
}, [deferredPrompt]);
|
||||
|
||||
const installMessenger = useCallback(async (): Promise<InstallResult> => {
|
||||
if (!messengerPrompt) {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
await messengerPrompt.prompt();
|
||||
const choice = await messengerPrompt.userChoice;
|
||||
if (choice.outcome === "accepted") {
|
||||
setMessengerPrompt(null);
|
||||
setIsMessengerInstalled(true);
|
||||
markMessengerInstalled();
|
||||
return "accepted";
|
||||
}
|
||||
return "dismissed";
|
||||
}, [messengerPrompt]);
|
||||
|
||||
const value = useMemo<PwaContextValue>(
|
||||
() => ({
|
||||
canInstall: !isInstalled && deferredPrompt !== null,
|
||||
isInstalled,
|
||||
isRegistrationReady,
|
||||
install,
|
||||
canInstallMessenger: messengerPrompt !== null || supportsManualIosInstall(),
|
||||
isMessengerInstalled,
|
||||
installMessenger,
|
||||
}),
|
||||
[deferredPrompt, install, isInstalled, isRegistrationReady],
|
||||
[
|
||||
deferredPrompt,
|
||||
install,
|
||||
installMessenger,
|
||||
isInstalled,
|
||||
isMessengerInstalled,
|
||||
isRegistrationReady,
|
||||
messengerPrompt,
|
||||
],
|
||||
);
|
||||
|
||||
return <PwaContext.Provider value={value}>{children}</PwaContext.Provider>;
|
||||
|
||||
@ -15,6 +15,13 @@ import {
|
||||
} from '@/lib/push';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getDeviceGroupId,
|
||||
getDeviceName,
|
||||
getInstallationId,
|
||||
isMessengerLaunch,
|
||||
type NotificationAppKind,
|
||||
} from '@/lib/notifications/installations';
|
||||
|
||||
interface UseWebPushReturn {
|
||||
isSupported: boolean;
|
||||
@ -29,7 +36,13 @@ interface UseWebPushReturn {
|
||||
/**
|
||||
* Хук для управления Web Push подписками
|
||||
*/
|
||||
export function useWebPush(): UseWebPushReturn {
|
||||
export function useWebPush({
|
||||
appKind = 'main',
|
||||
enabled = true,
|
||||
}: {
|
||||
appKind?: NotificationAppKind;
|
||||
enabled?: boolean;
|
||||
} = {}): UseWebPushReturn {
|
||||
const [isSupported] = useState(() => isPushSupported());
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -39,13 +52,14 @@ export function useWebPush(): UseWebPushReturn {
|
||||
|
||||
// Инициализация: проверка поддержки и текущего статуса
|
||||
useEffect(() => {
|
||||
if (!isSupported) {
|
||||
if (!enabled || !isSupported) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Получаем разрешение
|
||||
const perm = getNotificationPermission();
|
||||
setPermission(perm);
|
||||
@ -55,13 +69,23 @@ export function useWebPush(): UseWebPushReturn {
|
||||
setVapidPublicKey(response.vapid_public_key);
|
||||
|
||||
// Регистрируем Service Worker
|
||||
const registration = await registerServiceWorker();
|
||||
const registration = await registerServiceWorker(appKind);
|
||||
setSwRegistration(registration);
|
||||
|
||||
// Проверяем текущую подписку
|
||||
const subscription = await getCurrentSubscription(registration);
|
||||
setIsSubscribed(subscription !== null);
|
||||
|
||||
if (appKind === 'main' || isMessengerLaunch()) {
|
||||
await apiClient.heartbeatNotificationInstallation({
|
||||
installation_id: getInstallationId(appKind),
|
||||
device_group_id: getDeviceGroupId(),
|
||||
app_kind: appKind,
|
||||
device_name: getDeviceName(),
|
||||
installed: appKind === 'messenger' ? isMessengerLaunch() : true,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[useWebPush] Инициализация завершена, подписка:', subscription !== null);
|
||||
} catch (error) {
|
||||
console.error('[useWebPush] Ошибка инициализации:', error);
|
||||
@ -72,7 +96,7 @@ export function useWebPush(): UseWebPushReturn {
|
||||
}
|
||||
|
||||
init();
|
||||
}, [isSupported]);
|
||||
}, [appKind, enabled, isSupported]);
|
||||
|
||||
// Запрос разрешения на уведомления
|
||||
const requestPermission = useCallback(async (): Promise<NotificationPermission> => {
|
||||
@ -107,7 +131,11 @@ export function useWebPush(): UseWebPushReturn {
|
||||
// Отправляем на сервер
|
||||
await apiClient.subscribePush({
|
||||
...subscriptionData,
|
||||
device_name: getBrowserInfo(),
|
||||
device_name: getDeviceName(),
|
||||
installation_id: getInstallationId(appKind),
|
||||
device_group_id: getDeviceGroupId(),
|
||||
app_kind: appKind,
|
||||
installed: appKind === 'main' || isMessengerLaunch(),
|
||||
});
|
||||
|
||||
setIsSubscribed(true);
|
||||
@ -120,7 +148,7 @@ export function useWebPush(): UseWebPushReturn {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isSupported, swRegistration, vapidPublicKey, permission, requestPermission]);
|
||||
}, [appKind, isSupported, swRegistration, vapidPublicKey, permission, requestPermission]);
|
||||
|
||||
// Отписаться от push уведомлений
|
||||
const unsubscribe = useCallback(async (): Promise<boolean> => {
|
||||
@ -167,25 +195,3 @@ export function useWebPush(): UseWebPushReturn {
|
||||
requestPermission,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает информацию о браузере для device_name
|
||||
*/
|
||||
function getBrowserInfo(): string {
|
||||
if (typeof window === 'undefined') return 'Unknown';
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Unknown';
|
||||
|
||||
if (ua.indexOf('Firefox') > -1) {
|
||||
browser = 'Firefox';
|
||||
} else if (ua.indexOf('Edge') > -1) {
|
||||
browser = 'Edge';
|
||||
} else if (ua.indexOf('Chrome') > -1) {
|
||||
browser = 'Chrome';
|
||||
} else if (ua.indexOf('Safari') > -1) {
|
||||
browser = 'Safari';
|
||||
}
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { RequestFn } from './utils';
|
||||
import type {
|
||||
NotificationAppKind,
|
||||
NotificationDeliverySettings,
|
||||
NotificationDestination,
|
||||
} from '../notifications/installations';
|
||||
|
||||
export type UnreadNotificationsSummary = {
|
||||
total: number;
|
||||
@ -44,11 +49,46 @@ export function createNotificationsApi(request: RequestFn) {
|
||||
},
|
||||
// Push
|
||||
getVapidPublicKey: (): Promise<{ vapid_public_key: string }> => request('/api/v1/notifications/push/vapid-key/'),
|
||||
subscribePush: (data: { endpoint: string; keys: { p256dh: string; auth: string }; device_name?: string }): Promise<{ status: string; message: string; created: boolean }> =>
|
||||
subscribePush: (data: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
device_name?: string;
|
||||
installation_id?: string;
|
||||
device_group_id?: string;
|
||||
app_kind?: NotificationAppKind;
|
||||
installed?: boolean;
|
||||
}): Promise<{ status: string; message: string; created: boolean }> =>
|
||||
request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }),
|
||||
unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> =>
|
||||
request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }),
|
||||
getPushSubscriptions: (): Promise<{ subscriptions: any[] }> => request('/api/v1/notifications/push/subscriptions/'),
|
||||
heartbeatNotificationInstallation: (data: {
|
||||
installation_id: string;
|
||||
device_group_id: string;
|
||||
app_kind: NotificationAppKind;
|
||||
device_name?: string;
|
||||
installed?: boolean;
|
||||
}) => request('/api/v1/notifications/push/installations/heartbeat/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getNotificationDeliverySettings: (deviceGroupId: string): Promise<NotificationDeliverySettings> =>
|
||||
request(`/api/v1/notifications/push/delivery-routing/?device_group_id=${encodeURIComponent(deviceGroupId)}`),
|
||||
updateNotificationDeliverySettings: (data: {
|
||||
device_group_id: string;
|
||||
routes: Record<string, NotificationDestination[]>;
|
||||
}): Promise<NotificationDeliverySettings> => request('/api/v1/notifications/push/delivery-routing/', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
disconnectNotificationInstallation: (data: {
|
||||
device_group_id: string;
|
||||
app_kind: NotificationAppKind;
|
||||
}): Promise<{ status: string; routes: Record<string, NotificationDestination[]> }> =>
|
||||
request('/api/v1/notifications/push/installations/disconnect/', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
// CRUD
|
||||
deleteNotification: (id: number): Promise<void> => request(`/api/v1/notifications/${id}/`, { method: 'DELETE' }),
|
||||
deleteAllReadNotifications: (): Promise<{ status: string; count: number }> => request('/api/v1/notifications/delete-all-read/', { method: 'DELETE' }),
|
||||
|
||||
5
frontend/src/lib/messages/workspace.d.ts
vendored
5
frontend/src/lib/messages/workspace.d.ts
vendored
@ -6,5 +6,10 @@ export function resolveMessagesWorkspaceMode(stored: {
|
||||
legacyImmersive: string | null;
|
||||
}): MessagesWorkspaceMode;
|
||||
|
||||
export function resolveEffectiveMessagesWorkspaceMode(
|
||||
storedMode: MessagesWorkspaceMode,
|
||||
messengerLaunch: boolean,
|
||||
): MessagesWorkspaceMode;
|
||||
|
||||
export function getMessagesActiveChatId(pathname: string): number | undefined;
|
||||
export function isMessagesDialogPath(pathname: string): boolean;
|
||||
|
||||
@ -14,6 +14,15 @@ export function resolveMessagesWorkspaceMode({ current, legacyFocus, legacyImmer
|
||||
return legacyFocus === "0" ? "standard" : "wide";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessagesWorkspaceMode} storedMode
|
||||
* @param {boolean} messengerLaunch
|
||||
* @returns {MessagesWorkspaceMode}
|
||||
*/
|
||||
export function resolveEffectiveMessagesWorkspaceMode(storedMode, messengerLaunch) {
|
||||
return messengerLaunch ? "immersive" : storedMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @returns {number | undefined}
|
||||
|
||||
@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
getMessagesActiveChatId,
|
||||
isMessagesDialogPath,
|
||||
resolveEffectiveMessagesWorkspaceMode,
|
||||
resolveMessagesWorkspaceMode,
|
||||
} from "./workspace.js";
|
||||
|
||||
@ -19,6 +20,12 @@ test("workspace mode migrates legacy settings", () => {
|
||||
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: null, legacyImmersive: null }), "wide");
|
||||
});
|
||||
|
||||
test("installed messenger always uses the isolated workspace without changing the stored mode", () => {
|
||||
assert.equal(resolveEffectiveMessagesWorkspaceMode("standard", true), "immersive");
|
||||
assert.equal(resolveEffectiveMessagesWorkspaceMode("wide", true), "immersive");
|
||||
assert.equal(resolveEffectiveMessagesWorkspaceMode("standard", false), "standard");
|
||||
});
|
||||
|
||||
test("message paths expose chat ownership without treating settings as a dialog", () => {
|
||||
assert.equal(getMessagesActiveChatId("/messages/42"), 42);
|
||||
assert.equal(getMessagesActiveChatId("/messages/42/settings"), 42);
|
||||
|
||||
45
frontend/src/lib/notification-installations.test.js
Normal file
45
frontend/src/lib/notification-installations.test.js
Normal file
@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearPendingMessengerRoute,
|
||||
getDeviceGroupId,
|
||||
getInstallationId,
|
||||
getPendingMessengerRoute,
|
||||
savePendingMessengerRoute,
|
||||
} from "./notifications/installations.ts";
|
||||
|
||||
function createStorage() {
|
||||
const values = new Map();
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
test("notification installations keep a stable device group and separate app ids", () => {
|
||||
globalThis.window = {
|
||||
localStorage: createStorage(),
|
||||
};
|
||||
|
||||
const deviceGroupId = getDeviceGroupId();
|
||||
assert.equal(getDeviceGroupId(), deviceGroupId);
|
||||
assert.notEqual(getInstallationId("main"), getInstallationId("messenger"));
|
||||
assert.equal(getInstallationId("main"), getInstallationId("main"));
|
||||
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
test("pending messenger delivery is stored until setup completes", () => {
|
||||
globalThis.window = {
|
||||
localStorage: createStorage(),
|
||||
};
|
||||
|
||||
savePendingMessengerRoute(["main", "messenger"]);
|
||||
assert.deepEqual(getPendingMessengerRoute(), ["main", "messenger"]);
|
||||
clearPendingMessengerRoute();
|
||||
assert.equal(getPendingMessengerRoute(), null);
|
||||
|
||||
delete globalThis.window;
|
||||
});
|
||||
129
frontend/src/lib/notifications/installations.ts
Normal file
129
frontend/src/lib/notifications/installations.ts
Normal file
@ -0,0 +1,129 @@
|
||||
export type NotificationAppKind = "main" | "messenger";
|
||||
export type NotificationDestination = NotificationAppKind;
|
||||
|
||||
export type NotificationInstallation = {
|
||||
installation_id: string;
|
||||
device_group_id: string;
|
||||
app_kind: NotificationAppKind;
|
||||
device_name: string;
|
||||
installed: boolean;
|
||||
active: boolean;
|
||||
push_ready: boolean;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
export type NotificationDeliverySettings = {
|
||||
device_group_id: string;
|
||||
categories: Array<{ key: string; label: string }>;
|
||||
routes: Record<string, NotificationDestination[]>;
|
||||
installations: NotificationInstallation[];
|
||||
};
|
||||
|
||||
const DEVICE_GROUP_KEY = "corp:notifications:device-group";
|
||||
const INSTALLATION_KEY_PREFIX = "corp:notifications:installation:";
|
||||
const MESSENGER_PENDING_ROUTE_KEY = "corp:messenger:pending-message-route";
|
||||
const MESSENGER_INSTALLED_KEY = "corp:messenger:installed";
|
||||
|
||||
function createUuid(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (token) => {
|
||||
const random = Math.floor(Math.random() * 16);
|
||||
const value = token === "x" ? random : (random & 0x3) | 0x8;
|
||||
return value.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreateStorageValue(key: string): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const existing = window.localStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const value = createUuid();
|
||||
window.localStorage.setItem(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getDeviceGroupId(): string {
|
||||
return getOrCreateStorageValue(DEVICE_GROUP_KEY);
|
||||
}
|
||||
|
||||
export function getInstallationId(appKind: NotificationAppKind): string {
|
||||
return getOrCreateStorageValue(`${INSTALLATION_KEY_PREFIX}${appKind}`);
|
||||
}
|
||||
|
||||
export function isStandaloneApp(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean(
|
||||
window.matchMedia?.("(display-mode: standalone)").matches ||
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone,
|
||||
);
|
||||
}
|
||||
|
||||
export function isMessengerLaunch(): boolean {
|
||||
if (typeof window === "undefined" || !isStandaloneApp()) return false;
|
||||
const launchFlag = new URLSearchParams(window.location.search).get("pwa") === "messenger";
|
||||
const sessionFlag = window.sessionStorage.getItem(MESSENGER_INSTALLED_KEY) === "1";
|
||||
if (launchFlag) {
|
||||
window.sessionStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
|
||||
window.localStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
|
||||
}
|
||||
return launchFlag || sessionFlag;
|
||||
}
|
||||
|
||||
export function hasMessengerInstallMarker(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(MESSENGER_INSTALLED_KEY) === "1";
|
||||
}
|
||||
|
||||
export function markMessengerInstalled(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
|
||||
}
|
||||
|
||||
export function clearMessengerInstallMarker(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(MESSENGER_INSTALLED_KEY);
|
||||
window.sessionStorage.removeItem(MESSENGER_INSTALLED_KEY);
|
||||
}
|
||||
|
||||
export function savePendingMessengerRoute(
|
||||
destinations: NotificationDestination[],
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
MESSENGER_PENDING_ROUTE_KEY,
|
||||
JSON.stringify(destinations),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPendingMessengerRoute(): NotificationDestination[] | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
window.localStorage.getItem(MESSENGER_PENDING_ROUTE_KEY) || "null",
|
||||
);
|
||||
if (!Array.isArray(value)) return null;
|
||||
const normalized = value.filter(
|
||||
(item): item is NotificationDestination => item === "main" || item === "messenger",
|
||||
);
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingMessengerRoute(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(MESSENGER_PENDING_ROUTE_KEY);
|
||||
}
|
||||
|
||||
export function getDeviceName(): string {
|
||||
if (typeof window === "undefined") return "Unknown";
|
||||
const userAgentData = (navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
}).userAgentData;
|
||||
const platform = userAgentData?.platform || navigator.platform || "";
|
||||
const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) ? "Mobile" : "Desktop";
|
||||
return [platform, mobile].filter(Boolean).join(" · ") || "Browser";
|
||||
}
|
||||
@ -2,7 +2,8 @@
|
||||
* Web Push утилиты для управления подписками
|
||||
*/
|
||||
|
||||
import { registerAppServiceWorker } from "@/lib/pwa";
|
||||
import { registerAppServiceWorker, registerMessengerServiceWorker } from "@/lib/pwa";
|
||||
import type { NotificationAppKind } from "@/lib/notifications/installations";
|
||||
|
||||
/**
|
||||
* Конвертирует base64 строку в Uint8Array для applicationServerKey
|
||||
@ -69,8 +70,12 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
|
||||
/**
|
||||
* Регистрирует Service Worker
|
||||
*/
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
const registration = await registerAppServiceWorker();
|
||||
export async function registerServiceWorker(
|
||||
appKind: NotificationAppKind = "main",
|
||||
): Promise<ServiceWorkerRegistration> {
|
||||
const registration = appKind === "messenger"
|
||||
? await registerMessengerServiceWorker()
|
||||
: await registerAppServiceWorker();
|
||||
if (!registration) {
|
||||
throw new Error('Service Worker не поддерживается');
|
||||
}
|
||||
|
||||
@ -21,8 +21,10 @@ export function isStandaloneDisplayMode(): boolean {
|
||||
}
|
||||
|
||||
async function unregisterDevelopmentServiceWorker(): Promise<void> {
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
await registration?.unregister().catch(() => undefined);
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(
|
||||
registrations.map((registration) => registration.unregister().catch(() => false)),
|
||||
);
|
||||
|
||||
if ("caches" in window) {
|
||||
const cacheNames = await caches.keys().catch(() => []);
|
||||
@ -66,3 +68,27 @@ export async function registerAppServiceWorker(): Promise<ServiceWorkerRegistrat
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
export async function registerMessengerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = `${window.location.origin}/messages`;
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
const existingRegistration = registrations.find(
|
||||
(registration) => registration.scope === scope,
|
||||
);
|
||||
if (existingRegistration) {
|
||||
await existingRegistration.update().catch(() => undefined);
|
||||
return existingRegistration;
|
||||
}
|
||||
|
||||
return navigator.serviceWorker.register("/messenger-sw.js", {
|
||||
scope: "/messages",
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user