From fababde18c4610373287218bf39e3c33d07ae0dd Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 5 Aug 2026 15:47:12 +0300 Subject: [PATCH] feat(notifications): add messenger PWA delivery routing --- backend/notifications/admin.py | 29 +- backend/notifications/api/serializers.py | 31 ++ backend/notifications/api/urls.py | 15 + backend/notifications/api/views.py | 262 ++++++++++++++++- ...eliveryrouting_notificationinstallation.py | 56 ++++ backend/notifications/models.py | 75 +++++ backend/notifications/routing.py | 73 +++++ backend/notifications/senders/push.py | 112 +++++++- .../notifications/test_notifications_api.py | 137 ++++++++- .../api/v1/notifications/test_push_sender.py | 209 +++++++++++++- frontend/public/messenger-icon-180.png | Bin 0 -> 2632 bytes frontend/public/messenger-icon-192.png | Bin 0 -> 2847 bytes .../public/messenger-icon-512-maskable.png | Bin 0 -> 3225 bytes frontend/public/messenger-icon-512.png | Bin 0 -> 4637 bytes frontend/public/messenger-icon-source.svg | 11 + frontend/public/messenger-sw.js | 4 + frontend/public/sw.js | 11 +- frontend/src/app/manifest.ts | 32 --- .../src/app/manifest.webmanifest/route.ts | 41 +++ frontend/src/app/messages/layout.tsx | 19 ++ frontend/src/app/messages/page.tsx | 4 +- .../src/app/messenger.webmanifest/route.ts | 41 +++ frontend/src/app/settings/page.tsx | 3 + .../messages/MessagesFocusSidebar.tsx | 7 +- .../messages/MessagesImmersiveModeButton.tsx | 28 -- .../messages/MessagesInstallButton.tsx | 266 ++++++++++++++++++ .../messages/MessagesWorkspaceShell.tsx | 16 +- .../settings/NotificationDeliveryApps.tsx | 233 +++++++++++++++ frontend/src/contexts/PwaContext.tsx | 71 ++++- frontend/src/hooks/useWebPush.ts | 62 ++-- frontend/src/lib/api/notifications.ts | 42 ++- frontend/src/lib/messages/workspace.d.ts | 5 + frontend/src/lib/messages/workspace.js | 9 + frontend/src/lib/messages/workspace.test.js | 7 + .../lib/notification-installations.test.js | 45 +++ .../src/lib/notifications/installations.ts | 129 +++++++++ frontend/src/lib/push.ts | 11 +- frontend/src/lib/pwa.ts | 30 +- 38 files changed, 1985 insertions(+), 141 deletions(-) create mode 100644 backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py create mode 100644 backend/notifications/routing.py create mode 100644 frontend/public/messenger-icon-180.png create mode 100644 frontend/public/messenger-icon-192.png create mode 100644 frontend/public/messenger-icon-512-maskable.png create mode 100644 frontend/public/messenger-icon-512.png create mode 100644 frontend/public/messenger-icon-source.svg create mode 100644 frontend/public/messenger-sw.js delete mode 100644 frontend/src/app/manifest.ts create mode 100644 frontend/src/app/manifest.webmanifest/route.ts create mode 100644 frontend/src/app/messenger.webmanifest/route.ts delete mode 100644 frontend/src/components/messages/MessagesImmersiveModeButton.tsx create mode 100644 frontend/src/components/messages/MessagesInstallButton.tsx create mode 100644 frontend/src/components/settings/NotificationDeliveryApps.tsx create mode 100644 frontend/src/lib/notification-installations.test.js create mode 100644 frontend/src/lib/notifications/installations.ts diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py index bea16177..b78dd5a5 100644 --- a/backend/notifications/admin.py +++ b/backend/notifications/admin.py @@ -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") diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index 404f5259..9c2cda11 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -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")) diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py index db7aeaab..60e1c2a6 100644 --- a/backend/notifications/api/urls.py +++ b/backend/notifications/api/urls.py @@ -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", + ), ] diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 4990779d..6c566875 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -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 diff --git a/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py new file mode 100644 index 00000000..6a27ab38 --- /dev/null +++ b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py @@ -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')], + }, + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 99ab9ed1..2c2c50e1 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -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 diff --git a/backend/notifications/routing.py b/backend/notifications/routing.py new file mode 100644 index 00000000..754a879a --- /dev/null +++ b/backend/notifications/routing.py @@ -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 + } diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py index ca6666c6..e570259f 100644 --- a/backend/notifications/senders/push.py +++ b/backend/notifications/senders/push.py @@ -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") diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index d718e4f4..e6899257 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -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() diff --git a/backend/tests/api/v1/notifications/test_push_sender.py b/backend/tests/api/v1/notifications/test_push_sender.py index 7769e43a..23cf9590 100644 --- a/backend/tests/api/v1/notifications/test_push_sender.py +++ b/backend/tests/api/v1/notifications/test_push_sender.py @@ -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 diff --git a/frontend/public/messenger-icon-180.png b/frontend/public/messenger-icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..34f8a8308425cc10280c72e8905156794dfd5b0c GIT binary patch literal 2632 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 + + + diff --git a/frontend/public/messenger-sw.js b/frontend/public/messenger-sw.js new file mode 100644 index 00000000..71dba6ae --- /dev/null +++ b/frontend/public/messenger-sw.js @@ -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"); diff --git a/frontend/public/sw.js b/frontend/public/sw.js index e0615b51..2bb93918 100644 --- a/frontend/public/sw.js +++ b/frontend/public/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') || diff --git a/frontend/src/app/manifest.ts b/frontend/src/app/manifest.ts deleted file mode 100644 index f5b72c21..00000000 --- a/frontend/src/app/manifest.ts +++ /dev/null @@ -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", - }, - ], - }; -} diff --git a/frontend/src/app/manifest.webmanifest/route.ts b/frontend/src/app/manifest.webmanifest/route.ts new file mode 100644 index 00000000..71e401e6 --- /dev/null +++ b/frontend/src/app/manifest.webmanifest/route.ts @@ -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", + }, + }, + ); +} diff --git a/frontend/src/app/messages/layout.tsx b/frontend/src/app/messages/layout.tsx index 60793429..fe7f342c 100644 --- a/frontend/src/app/messages/layout.tsx +++ b/frontend/src/app/messages/layout.tsx @@ -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 {children}; } diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 14aec280..da326ceb 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -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() {

Сообщения

setMode(enabled ? "wide" : "standard")} /> - setMode(enabled ? "immersive" : "standard")} /> + diff --git a/frontend/src/app/messenger.webmanifest/route.ts b/frontend/src/app/messenger.webmanifest/route.ts new file mode 100644 index 00000000..bd737100 --- /dev/null +++ b/frontend/src/app/messenger.webmanifest/route.ts @@ -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", + }, + }, + ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index e37b337d..86e4b24c 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -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() {
+ +
diff --git a/frontend/src/components/messages/MessagesFocusSidebar.tsx b/frontend/src/components/messages/MessagesFocusSidebar.tsx index 2c68665a..da8c3f3e 100644 --- a/frontend/src/components/messages/MessagesFocusSidebar.tsx +++ b/frontend/src/components/messages/MessagesFocusSidebar.tsx @@ -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")} /> - onModeChange(enabled ? "immersive" : "wide")} - /> + - ); -} diff --git a/frontend/src/components/messages/MessagesInstallButton.tsx b/frontend/src/components/messages/MessagesInstallButton.tsx new file mode 100644 index 00000000..f290df6a --- /dev/null +++ b/frontend/src/components/messages/MessagesInstallButton.tsx @@ -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("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 ( + <> + + + setOpen(false)} + title={messengerLaunch ? "Завершить настройку" : "Установить мессенджер"} + size="sm" + footer={ +
+ {showIosInstructions ? ( + + ) : ( + <> + + + + )} +
+ } + > + {showIosInstructions ? ( +
+
+
+ +
+

+ {isIosDevice() ? "Установка на iPhone или iPad" : "Завершите установку мессенджера"} +

+

+ {isIosDevice() + ? "Нажмите «Поделиться» в браузере, выберите «На экран Домой», затем откройте установленный мессенджер." + : "Используйте команду установки в меню браузера, затем откройте CORP Мессенджер из списка приложений."} +

+
+
+
+

+ Настройки обычного CORP останутся активными, пока мессенджер не запустится и не получит разрешение на уведомления. +

+
+ ) : messengerLaunch ? ( +
+
+
+ +
+

Разрешите системные уведомления

+

+ После подтверждения выбранный маршрут сообщений начнет действовать на этом устройстве. +

+
+
+
+
+ ) : ( +
+

+ Выберите, где получать уведомления о сообщениях на этом устройстве. +

+
+ {CHOICES.map((item) => { + const selected = choice === item.value; + return ( + + ); + })} +
+ {isIosDevice() ? ( +

На iOS после выбора появится инструкция добавления на домашний экран.

+ ) : null} +
+ )} +
+ + ); +} diff --git a/frontend/src/components/messages/MessagesWorkspaceShell.tsx b/frontend/src/components/messages/MessagesWorkspaceShell.tsx index ed38921b..12438fa3 100644 --- a/frontend/src/components/messages/MessagesWorkspaceShell.tsx +++ b/frontend/src/components/messages/MessagesWorkspaceShell.tsx @@ -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); diff --git a/frontend/src/components/settings/NotificationDeliveryApps.tsx b/frontend/src/components/settings/NotificationDeliveryApps.tsx new file mode 100644 index 00000000..bd9c1b25 --- /dev/null +++ b/frontend/src/components/settings/NotificationDeliveryApps.tsx @@ -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(null); + const [savedRoutes, setSavedRoutes] = useState>({}); + 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 ( +
+

Загрузка приложений доставки...

+
+ ); + } + if (!settings) return null; + + return ( +
+
+
+

Приложения доставки

+

+ Настройки относятся к текущему устройству. Тип события можно доставлять в одно или оба приложения. +

+
+ +
+ +
+
+
+ + + +
+

Основной CORP

+

Подключен на этом устройстве

+
+
+
+
+
+ + + +
+

CORP Мессенджер

+

+ {messengerReady ? "Установлен, push подключен" : "Не подключен на этом устройстве"} +

+
+
+ {!messengerReady ? ( + + Перейти к установке + + ) : ( + + )} +
+
+ +
+
+
+ Тип уведомлений + CORP + Мессенджер +
+ {settings.categories.map((category) => ( +
+ {category.label} + {(["main", "messenger"] as const).map((destination) => { + const enabled = destinationEnabled(settings.routes[category.key], destination); + const disabled = destination === "messenger" && !messengerReady; + return ( + + ); + })} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/contexts/PwaContext.tsx b/frontend/src/contexts/PwaContext.tsx index 103a306f..455ad578 100644 --- a/frontend/src/contexts/PwaContext.tsx +++ b/frontend/src/contexts/PwaContext.tsx @@ -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; + canInstallMessenger: boolean; + isMessengerInstalled: boolean; + installMessenger: () => Promise; }; const PwaContext = createContext(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(null); - const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode()); + const [messengerPrompt, setMessengerPrompt] = useState(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 => { if (!deferredPrompt) { @@ -95,14 +127,41 @@ export function PwaProvider({ children }: { children: ReactNode }) { return "dismissed"; }, [deferredPrompt]); + const installMessenger = useCallback(async (): Promise => { + 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( () => ({ 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 {children}; diff --git a/frontend/src/hooks/useWebPush.ts b/frontend/src/hooks/useWebPush.ts index 5e03c99f..15ea246b 100644 --- a/frontend/src/hooks/useWebPush.ts +++ b/frontend/src/hooks/useWebPush.ts @@ -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 => { @@ -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 => { @@ -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; -} diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 87272357..44545c4f 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -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 => + request(`/api/v1/notifications/push/delivery-routing/?device_group_id=${encodeURIComponent(deviceGroupId)}`), + updateNotificationDeliverySettings: (data: { + device_group_id: string; + routes: Record; + }): Promise => 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 }> => + request('/api/v1/notifications/push/installations/disconnect/', { + method: 'DELETE', + body: JSON.stringify(data), + }), // CRUD deleteNotification: (id: number): Promise => request(`/api/v1/notifications/${id}/`, { method: 'DELETE' }), deleteAllReadNotifications: (): Promise<{ status: string; count: number }> => request('/api/v1/notifications/delete-all-read/', { method: 'DELETE' }), diff --git a/frontend/src/lib/messages/workspace.d.ts b/frontend/src/lib/messages/workspace.d.ts index e7ccb53d..bc3c6a01 100644 --- a/frontend/src/lib/messages/workspace.d.ts +++ b/frontend/src/lib/messages/workspace.d.ts @@ -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; diff --git a/frontend/src/lib/messages/workspace.js b/frontend/src/lib/messages/workspace.js index a69d873a..490c30cd 100644 --- a/frontend/src/lib/messages/workspace.js +++ b/frontend/src/lib/messages/workspace.js @@ -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} diff --git a/frontend/src/lib/messages/workspace.test.js b/frontend/src/lib/messages/workspace.test.js index f701a5bd..cfa9c2bb 100644 --- a/frontend/src/lib/messages/workspace.test.js +++ b/frontend/src/lib/messages/workspace.test.js @@ -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); diff --git a/frontend/src/lib/notification-installations.test.js b/frontend/src/lib/notification-installations.test.js new file mode 100644 index 00000000..467bda8a --- /dev/null +++ b/frontend/src/lib/notification-installations.test.js @@ -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; +}); diff --git a/frontend/src/lib/notifications/installations.ts b/frontend/src/lib/notifications/installations.ts new file mode 100644 index 00000000..c9c60f00 --- /dev/null +++ b/frontend/src/lib/notifications/installations.ts @@ -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; + 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"; +} diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts index 48f35846..60ffaf8c 100644 --- a/frontend/src/lib/push.ts +++ b/frontend/src/lib/push.ts @@ -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 { - const registration = await registerAppServiceWorker(); +export async function registerServiceWorker( + appKind: NotificationAppKind = "main", +): Promise { + const registration = appKind === "messenger" + ? await registerMessengerServiceWorker() + : await registerAppServiceWorker(); if (!registration) { throw new Error('Service Worker не поддерживается'); } diff --git a/frontend/src/lib/pwa.ts b/frontend/src/lib/pwa.ts index 9fff8193..8786deeb 100644 --- a/frontend/src/lib/pwa.ts +++ b/frontend/src/lib/pwa.ts @@ -21,8 +21,10 @@ export function isStandaloneDisplayMode(): boolean { } async function unregisterDevelopmentServiceWorker(): Promise { - 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 { + 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", + }); +}