(global) add mention notifications via UserEvent (#621)

ThreadEvent IM mentions previously lived only inside the event payload,
with no per-user tracking, so a user had no way to see or filter the
threads where they were mentioned. The new UserEvent model materializes
mentions as first-class records (one row per mentioned user per event),
reconciled by a post_save signal whenever a ThreadEvent is created or
edited.

ThreadEvent edits and deletes are now bounded by THREAD_EVENT_EDIT_DELAY
(1h default) so UserEvent records cannot drift out of sync with stale
audit data past the window.
This commit is contained in:
Jean-Baptiste PENRATH
2026-04-10 00:54:39 +02:00
committed by GitHub
parent 4a59033a98
commit 1044614a76
54 changed files with 3621 additions and 294 deletions

View File

@@ -286,6 +286,7 @@ without redeploying the frontend (the flag is pulled from
| `MAX_OUTGOING_BODY_SIZE` | `5242880` | Maximum size in bytes for outgoing email body (text + HTML) (5MB) | Optional |
| `MAX_TEMPLATE_IMAGE_SIZE` | `2097152` | Maximum size in bytes for images embedded in templates and signatures (2MB) | Optional |
| `MAX_RECIPIENTS_PER_MESSAGE` | `500` | Maximum number of recipients per message (to + cc + bcc) | Optional |
| `MAX_THREAD_EVENT_EDIT_DELAY` | `3600` | Time window in seconds during which a ThreadEvent (internal comment) can still be edited or deleted after creation. Set to `0` to disable the restriction. | Optional |
### Model custom attributes schema

View File

@@ -544,11 +544,36 @@ class ThreadEventInline(admin.TabularInline):
extra = 0
class UserEventInline(admin.TabularInline):
"""Inline class for the UserEvent model.
UserEvent entries are created exclusively by business logic (mentions,
assignments) and must never be edited via the admin: editing ``thread_event``
would desynchronize ``user_event.thread`` from
``user_event.thread_event.thread``, breaking mention filters and unread flags.
"""
model = models.UserEvent
readonly_fields = (
"user",
"thread",
"thread_event",
"type",
"read_at",
"created_at",
)
can_delete = False
extra = 0
def has_add_permission(self, request, obj=None):
return False
@admin.register(models.Thread)
class ThreadAdmin(admin.ModelAdmin):
"""Admin class for the Thread model"""
inlines = [ThreadAccessInline, ThreadEventInline]
inlines = [ThreadAccessInline, ThreadEventInline, UserEventInline]
list_display = (
"id",
"subject",

View File

@@ -4852,6 +4852,14 @@
},
"description": "Filter threads with draft messages (1=true, 0=false)."
},
{
"in": "query",
"name": "has_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "has_messages",
@@ -4892,6 +4900,14 @@
},
"description": "Filter threads with unread messages (1=true, 0=false). Requires mailbox_id."
},
{
"in": "query",
"name": "has_unread_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with unread mentions for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "is_spam",
@@ -5800,6 +5816,49 @@
}
}
},
"/api/v1.0/threads/{thread_id}/events/{id}/read-mention/": {
"patch": {
"operationId": "threads_events_read_mention_partial_update",
"description": "Mark the current user's unread MENTION on this ThreadEvent as read.\n\nReturns 204 even when no UserEvent matches (idempotent); the thread\nevent itself is resolved via the standard ``get_object`` lookup so a\nmissing event yields 404.",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "string",
"format": "uuid",
"description": "primary key for the record as UUID"
},
"required": true
},
{
"in": "path",
"name": "thread_id",
"schema": {
"type": "string",
"format": "uuid"
},
"required": true
}
],
"tags": [
"thread-events"
],
"security": [
{
"cookieAuth": []
}
],
"responses": {
"204": {
"description": "No response body"
},
"404": {
"description": "Thread event not found"
}
}
}
},
"/api/v1.0/threads/{thread_id}/users/": {
"get": {
"operationId": "threads_users_list",
@@ -5877,6 +5936,14 @@
},
"description": "Filter threads with draft messages (1=true, 0=false)."
},
{
"in": "query",
"name": "has_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "has_sender",
@@ -5901,6 +5968,14 @@
},
"description": "Filter threads that are trashed (1=true, 0=false)."
},
{
"in": "query",
"name": "has_unread_mention",
"schema": {
"type": "integer"
},
"description": "Filter threads with unread mentions for the current user (1=true, 0=false)."
},
{
"in": "query",
"name": "label_slug",
@@ -5935,10 +6010,12 @@
"all",
"all_unread",
"has_delivery_failed",
"has_delivery_pending"
"has_delivery_pending",
"has_mention",
"has_unread_mention"
]
},
"description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'",
"description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'",
"required": true,
"explode": false,
"style": "form"
@@ -7132,15 +7209,23 @@
"readOnly": true
},
"count_unread_threads": {
"type": "string",
"type": "integer",
"description": "Return the number of threads with unread messages in the mailbox.",
"readOnly": true
},
"count_threads": {
"type": "string",
"type": "integer",
"description": "Return the number of threads in the mailbox.",
"readOnly": true
},
"count_delivering": {
"type": "string",
"type": "integer",
"description": "Return the number of threads with messages being delivered.",
"readOnly": true
},
"count_unread_mentions": {
"type": "integer",
"description": "Return the number of threads with unread mentions for the current user.",
"readOnly": true
},
"abilities": {
@@ -7212,6 +7297,7 @@
"abilities",
"count_delivering",
"count_threads",
"count_unread_mentions",
"count_unread_threads",
"email",
"id",
@@ -8830,6 +8916,10 @@
"type": "boolean",
"readOnly": true
},
"has_unread_mention": {
"type": "boolean",
"readOnly": true
},
"has_trashed": {
"type": "boolean",
"readOnly": true
@@ -8961,6 +9051,10 @@
"summary": {
"type": "string",
"readOnly": true
},
"events_count": {
"type": "integer",
"readOnly": true
}
},
"required": [
@@ -8968,6 +9062,7 @@
"active_messaged_at",
"archived_messaged_at",
"draft_messaged_at",
"events_count",
"has_active",
"has_archived",
"has_attachments",
@@ -8979,6 +9074,7 @@
"has_starred",
"has_trashed",
"has_unread",
"has_unread_mention",
"id",
"is_spam",
"is_trashed",
@@ -9191,6 +9287,14 @@
}
]
},
"has_unread_mention": {
"type": "boolean",
"readOnly": true
},
"is_editable": {
"type": "boolean",
"readOnly": true
},
"created_at": {
"type": "string",
"format": "date-time",
@@ -9211,7 +9315,9 @@
"channel",
"created_at",
"data",
"has_unread_mention",
"id",
"is_editable",
"thread",
"type",
"updated_at"

View File

@@ -168,16 +168,11 @@ class IsAllowedToAccess(IsAuthenticated):
return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists()
if isinstance(obj, models.ThreadEvent):
thread = obj.thread
has_access = models.ThreadAccess.objects.filter(
thread=thread, mailbox__accesses__user=user
# Write actions are handled by HasThreadEditAccess, so we only
# need to gate read access on any ThreadAccess for the thread.
return models.ThreadAccess.objects.filter(
thread=obj.thread, mailbox__accesses__user=user
).exists()
if not has_access:
return False
# Only the author can update or delete their own events
if view.action in ["update", "partial_update", "destroy"]:
return obj.author_id == user.id
return True
if isinstance(obj, (models.Message, models.Thread)):
thread = obj.thread if isinstance(obj, models.Message) else obj
@@ -543,10 +538,47 @@ class HasThreadEditAccess(IsAuthenticated):
message = "You do not have permission to perform this action on this thread."
def has_object_permission(self, request, view, obj):
"""Check if user has editor access to the thread."""
def has_permission(self, request, view):
"""Check editor access up-front on nested thread routes.
On nested routes (e.g. ``/threads/{thread_id}/events/``), the thread
id comes from the URL and we can enforce the editor role before the
view resolves any object — required for ``create`` where no object
exists yet. On top-level routes (e.g. ``/threads/{pk}/``), defer to
``has_object_permission``.
"""
if not super().has_permission(request, view):
return False
thread_id_from_url = view.kwargs.get("thread_id")
if thread_id_from_url is None:
return True
return models.ThreadAccess.objects.filter(
thread=obj,
thread_id=thread_id_from_url,
role__in=enums.THREAD_ROLES_CAN_EDIT,
mailbox__accesses__user=request.user,
).exists()
def has_object_permission(self, request, view, obj):
"""Check editor access on the thread the object belongs to.
Supports both ``Thread`` and ``ThreadEvent`` objects. For
``ThreadEvent``, also enforces that only the event author can perform
update or destroy actions.
"""
if isinstance(obj, models.ThreadEvent):
if (
view.action in ("update", "partial_update", "destroy")
and obj.author_id != request.user.id
):
return False
thread = obj.thread
else:
thread = obj
return models.ThreadAccess.objects.filter(
thread=thread,
mailbox__accesses__user=request.user,
role__in=enums.THREAD_ROLES_CAN_EDIT,
).exists()

View File

@@ -256,6 +256,7 @@ class MailboxSerializer(AbilitiesModelSerializer):
count_unread_threads = serializers.SerializerMethodField(read_only=True)
count_threads = serializers.SerializerMethodField(read_only=True)
count_delivering = serializers.SerializerMethodField(read_only=True)
count_unread_mentions = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Mailbox
@@ -267,6 +268,7 @@ class MailboxSerializer(AbilitiesModelSerializer):
"count_unread_threads",
"count_threads",
"count_delivering",
"count_unread_mentions",
]
read_only_fields = fields
@@ -298,7 +300,7 @@ class MailboxSerializer(AbilitiesModelSerializer):
return None
def _get_cached_counts(self, instance):
"""Get or compute cached counts for the instance in a single query."""
"""Get or compute cached counts for the instance."""
cache_key = f"_counts_{instance.pk}"
if not hasattr(self, cache_key):
counts = instance.thread_accesses.aggregate(
@@ -313,21 +315,43 @@ class MailboxSerializer(AbilitiesModelSerializer):
distinct=True,
),
)
# Count distinct threads in this mailbox with an active unread
# mention UserEvent for the current user. Done in a separate query
# to avoid join-multiplication breaking the other counts above.
request = self.context.get("request")
if request and request.user.is_authenticated:
counts["count_unread_mentions"] = (
models.UserEvent.objects.filter(
user=request.user,
type=enums.UserEventTypeChoices.MENTION,
read_at__isnull=True,
thread__accesses__mailbox=instance,
)
.values("thread_id")
.distinct()
.count()
)
else:
counts["count_unread_mentions"] = 0
setattr(self, cache_key, counts)
return getattr(self, cache_key)
def get_count_unread_threads(self, instance):
def get_count_unread_threads(self, instance) -> int:
"""Return the number of threads with unread messages in the mailbox."""
return self._get_cached_counts(instance)["count_unread_threads"]
def get_count_threads(self, instance):
def get_count_threads(self, instance) -> int:
"""Return the number of threads in the mailbox."""
return self._get_cached_counts(instance)["count_threads"]
def get_count_delivering(self, instance):
def get_count_delivering(self, instance) -> int:
"""Return the number of threads with messages being delivered."""
return self._get_cached_counts(instance)["count_delivering"]
def get_count_unread_mentions(self, instance) -> int:
"""Return the number of threads with unread mentions for the current user."""
return self._get_cached_counts(instance)["count_unread_mentions"]
@extend_schema_field(
{
"type": "object",
@@ -621,10 +645,12 @@ class ThreadSerializer(serializers.ModelSerializer):
sender_names = serializers.ListField(child=serializers.CharField(), read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
has_unread = serializers.SerializerMethodField(read_only=True)
has_unread_mention = serializers.SerializerMethodField(read_only=True)
has_starred = serializers.SerializerMethodField(read_only=True)
accesses = serializers.SerializerMethodField()
labels = serializers.SerializerMethodField()
summary = serializers.CharField(read_only=True)
events_count = serializers.IntegerField(read_only=True)
@extend_schema_field(serializers.BooleanField())
def get_has_unread(self, instance):
@@ -635,6 +661,15 @@ class ThreadSerializer(serializers.ModelSerializer):
"""
return getattr(instance, "_has_unread", False)
@extend_schema_field(serializers.BooleanField())
def get_has_unread_mention(self, instance):
"""Return whether the thread has unread mentions for the current user.
Requires the _has_unread_mention annotation (set by ThreadViewSet).
Returns False when the annotation is absent (no mailbox context).
"""
return getattr(instance, "_has_unread_mention", False)
@extend_schema_field(serializers.BooleanField())
def get_has_starred(self, instance):
"""Return whether the thread is starred for the current mailbox.
@@ -689,6 +724,7 @@ class ThreadSerializer(serializers.ModelSerializer):
"snippet",
"messages",
"has_unread",
"has_unread_mention",
"has_trashed",
"is_trashed",
"has_archived",
@@ -713,6 +749,7 @@ class ThreadSerializer(serializers.ModelSerializer):
"accesses",
"labels",
"summary",
"events_count",
]
read_only_fields = fields # Mark all as read-only for safety
@@ -968,6 +1005,8 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
author = UserWithoutAbilitiesSerializer(read_only=True)
data = ThreadEventDataField()
has_unread_mention = serializers.SerializerMethodField()
is_editable = serializers.SerializerMethodField()
class Meta:
model = models.ThreadEvent
@@ -979,6 +1018,8 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
"message",
"author",
"data",
"has_unread_mention",
"is_editable",
"created_at",
"updated_at",
]
@@ -987,11 +1028,23 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
"thread",
"channel",
"author",
"has_unread_mention",
"is_editable",
"created_at",
"updated_at",
]
create_only_fields = ["type", "message"]
@extend_schema_field(serializers.BooleanField())
def get_has_unread_mention(self, obj):
"""Return whether the event has an unread mention for the current user."""
return getattr(obj, "_has_unread_mention", False)
@extend_schema_field(serializers.BooleanField())
def get_is_editable(self, obj):
"""Return whether the event is still within the edit delay window."""
return obj.is_editable()
class MailboxAccessReadSerializer(serializers.ModelSerializer):
"""Serialize mailbox access information for read operations with nested user details.

View File

@@ -75,10 +75,7 @@ class ThreadViewSet(
"You do not have access to this mailbox."
) from e
queryset = queryset.annotate(
_has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id),
_has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id),
)
queryset = self._annotate_thread_permissions(queryset, user, mailbox_id)
if label_slug:
# Filter threads by label slug, ensuring user has access to the label's mailbox
@@ -107,6 +104,8 @@ class ThreadViewSet(
"has_messages": "has_messages",
"has_attachments": "has_attachments",
"has_delivery_pending": "has_delivery_pending",
"has_unread_mention": "_has_unread_mention",
"has_mention": "_has_mention",
"is_trashed": "is_trashed",
"is_spam": "is_spam",
}
@@ -136,6 +135,35 @@ class ThreadViewSet(
queryset = queryset.order_by(order_expression, "-created_at")
return queryset
@staticmethod
def _annotate_thread_permissions(queryset, user, mailbox_id):
"""Attach permission/state annotations expected by ThreadSerializer.
Shared between the regular DB queryset and the OpenSearch fallback so
both code paths always expose the same fields (e.g. ``events_count``),
avoiding silent divergence in the serialized payload.
"""
return queryset.annotate(
_has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id),
_has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id),
_has_unread_mention=Exists(
models.UserEvent.objects.filter(
thread=OuterRef("pk"),
user=user,
type=enums.UserEventTypeChoices.MENTION,
read_at__isnull=True,
)
),
_has_mention=Exists(
models.UserEvent.objects.filter(
thread=OuterRef("pk"),
user=user,
type=enums.UserEventTypeChoices.MENTION,
)
),
events_count=Count("events", distinct=True),
)
@staticmethod
def _get_order_expression(query_params):
"""Return the ordering expression based on the active view filter."""
@@ -225,6 +253,18 @@ class ThreadViewSet(
"Filter threads with delivery pending messages: sending, retry or failed (1=true, 0=false)."
),
),
OpenApiParameter(
name="has_unread_mention",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter threads with unread mentions for the current user (1=true, 0=false).",
),
OpenApiParameter(
name="has_mention",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).",
),
OpenApiParameter(
name="stats_fields",
type=OpenApiTypes.STR,
@@ -233,7 +273,7 @@ class ThreadViewSet(
description="""Comma-separated list of fields to aggregate.
Special values: 'all' (count all threads), 'all_unread' (count all unread threads).
Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,
has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages.
has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.
Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.
Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'""",
enum=list(enums.THREAD_STATS_FIELDS_MAP.keys()),
@@ -296,6 +336,8 @@ class ThreadViewSet(
"has_active",
"has_delivery_failed",
"has_delivery_pending",
"has_unread_mention",
"has_mention",
"is_spam",
"has_messages",
}
@@ -303,6 +345,11 @@ class ThreadViewSet(
# Special fields
special_fields = {"all", "all_unread"}
# Base fields that cannot be combined with the "_unread" suffix because
# they are annotations (not real model columns) and their unread variant
# is either already exposed (has_unread_mention) or meaningless.
annotation_fields = {"has_mention", "has_unread_mention"}
# Validate requested fields
for field in requested_fields:
if field in special_fields:
@@ -315,6 +362,11 @@ class ThreadViewSet(
{"detail": f"Invalid base field in '{field}': {base_field}"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
if base_field in annotation_fields:
return drf.response.Response(
{"detail": f"Invalid field requested in stats_fields: {field}"},
status=drf.status.HTTP_400_BAD_REQUEST,
)
elif field in valid_base_fields:
continue
else:
@@ -323,9 +375,11 @@ class ThreadViewSet(
status=drf.status.HTTP_400_BAD_REQUEST,
)
# Build unread/starred conditions from annotations (always available)
# Build conditions from annotations (always available)
unread_condition = Q(_has_unread=True)
starred_condition = Q(_has_starred=True)
unread_mention_condition = Q(_has_unread_mention=True)
mention_condition = Q(_has_mention=True)
aggregations = {}
for field in requested_fields:
@@ -341,6 +395,10 @@ class ThreadViewSet(
aggregations[agg_key] = Count(
"pk", filter=starred_condition & unread_condition
)
elif field == "has_unread_mention":
aggregations[agg_key] = Count("pk", filter=unread_mention_condition)
elif field == "has_mention":
aggregations[agg_key] = Count("pk", filter=mention_condition)
elif field.endswith("_unread"):
base_field = field[:-7]
base_condition = Q(**{base_field: True})
@@ -462,6 +520,18 @@ class ThreadViewSet(
location=OpenApiParameter.QUERY,
description="Filter threads with unread messages (1=true, 0=false). Requires mailbox_id.",
),
OpenApiParameter(
name="has_unread_mention",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter threads with unread mentions for the current user (1=true, 0=false).",
),
OpenApiParameter(
name="has_mention",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).",
),
],
)
def list(self, request, *args, **kwargs):
@@ -527,13 +597,8 @@ class ThreadViewSet(
)
),
)
threads = threads.annotate(
_has_unread=models.ThreadAccess.thread_unread_filter(
request.user, mailbox_id
),
_has_starred=models.ThreadAccess.thread_starred_filter(
request.user, mailbox_id
),
threads = self._annotate_thread_permissions(
threads, request.user, mailbox_id
)
# Order the threads in the same order as the search results
@@ -753,6 +818,36 @@ class ThreadViewSet(
thread=new_thread, parent__thread=old_thread
).update(parent=None)
# Move ThreadEvents that belong chronologically to the new thread.
# Two cases:
# - ThreadEvent attached to a moved message (FK `message`) — it
# must follow its message so that `event.thread == message.thread`
# stays coherent.
# - Free ThreadEvent (no message FK) created at or after the split
# point — symmetric with how messages are split, so the timeline
# follows the cut.
event_ids = list(
models.ThreadEvent.objects.filter(thread=old_thread)
.filter(
Q(message__thread=new_thread)
| Q(
message__isnull=True,
created_at__gte=split_message.created_at,
)
)
.values_list("id", flat=True)
)
if event_ids:
models.ThreadEvent.objects.filter(id__in=event_ids).update(
thread=new_thread
)
# Keep the `UserEvent.thread` denormalization in sync with
# `thread_event.thread` — the ThreadViewSet annotations rely
# on filtering UserEvent by `thread` directly to avoid a JOIN.
models.UserEvent.objects.filter(thread_event_id__in=event_ids).update(
thread=new_thread
)
# Recalculate old thread snippet from its most recent remaining message
last_remaining = old_thread.messages.order_by("-created_at").first()
if last_remaining:

View File

@@ -1,11 +1,16 @@
"""API ViewSet for ThreadEvent model."""
from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from rest_framework import mixins, viewsets
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from core import models
from core import enums, models
from .. import permissions, serializers
@@ -30,19 +35,92 @@ class ThreadEventViewSet(
lookup_field = "id"
lookup_url_kwarg = "id"
def get_permissions(self):
"""Use HasThreadEditAccess for write actions.
ThreadEvent write operations require editor access, except
``read_mention`` which is a personal acknowledgement and only
requires read access on the thread.
"""
if self.action in ["list", "retrieve", "read_mention"]:
return [permissions.IsAuthenticated(), permissions.IsAllowedToAccess()]
return [permissions.HasThreadEditAccess()]
def get_queryset(self):
"""Restrict results to events for the specified thread."""
thread_id = self.kwargs.get("thread_id")
if not thread_id:
return models.ThreadEvent.objects.none()
return (
models.ThreadEvent.objects.filter(thread_id=thread_id)
.select_related("author", "channel", "message")
.order_by("created_at")
queryset = models.ThreadEvent.objects.filter(
thread_id=thread_id
).select_related("author", "channel", "message")
# Annotate with unread mention status for the current user
if self.request.user.is_authenticated:
queryset = queryset.annotate(
_has_unread_mention=Exists(
models.UserEvent.objects.filter(
thread_event=OuterRef("pk"),
user=self.request.user,
type=enums.UserEventTypeChoices.MENTION,
read_at__isnull=True,
)
)
)
return queryset.order_by("created_at")
def perform_create(self, serializer):
"""Set thread from URL and author from request user."""
thread = get_object_or_404(models.Thread, id=self.kwargs["thread_id"])
serializer.save(thread=thread, author=self.request.user)
def perform_update(self, serializer):
"""Reject updates made after the configured edit delay elapsed.
Past the window, the event (and any UserEvent MENTION records derived
from it) is considered historical and must remain immutable.
"""
if not serializer.instance.is_editable():
raise PermissionDenied(
"This event can no longer be edited (edit delay expired)."
)
serializer.save()
def perform_destroy(self, instance):
"""Reject deletions made after the configured edit delay elapsed.
Deletion is gated alongside edits so users cannot circumvent the
window by deleting and recreating an event.
"""
if not instance.is_editable():
raise PermissionDenied(
"This event can no longer be deleted (edit delay expired)."
)
instance.delete()
@extend_schema(
request=None,
responses={
204: OpenApiResponse(description="No response body"),
404: OpenApiResponse(description="Thread event not found"),
},
)
@action(detail=True, methods=["patch"], url_path="read-mention")
def read_mention(self, request, **kwargs):
"""Mark the current user's unread MENTION on this ThreadEvent as read.
Returns 204 even when no UserEvent matches (idempotent); the thread
event itself is resolved via the standard ``get_object`` lookup so a
missing event yields 404.
"""
thread_event = self.get_object()
models.UserEvent.objects.filter(
user=request.user,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
read_at__isnull=True,
).update(read_at=timezone.now())
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -91,6 +91,8 @@ THREAD_STATS_FIELDS_MAP = {
"all_unread": "all_unread",
"has_delivery_pending": "has_delivery_pending",
"has_delivery_failed": "has_delivery_failed",
"has_unread_mention": "has_unread_mention",
"has_mention": "has_mention",
}
@@ -256,6 +258,32 @@ CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY = frozenset(
)
def thread_event_type_choices():
"""Return ThreadEventTypeChoices.choices as a callable.
Used as ``choices=`` argument on ``ThreadEvent.type`` so Django stores the
import path of this function in migrations rather than the resolved list.
Adding new enum values then no longer triggers a schema migration.
"""
return ThreadEventTypeChoices.choices
class UserEventTypeChoices(models.TextChoices):
"""Defines the possible types of user events."""
MENTION = "mention", "Mention"
def user_event_type_choices():
"""Return UserEventTypeChoices.choices as a callable.
Used as ``choices=`` argument on ``UserEvent.type`` so Django stores the
import path of this function in migrations rather than the resolved list.
Adding new enum values then no longer triggers a schema migration.
"""
return UserEventTypeChoices.choices
class MessageTemplateTypeChoices(models.IntegerChoices):
"""Defines the possible types of message templates."""

View File

@@ -13,6 +13,7 @@ import factory.fuzzy
from faker import Faker
from core import enums, models
from core.enums import UserEventTypeChoices
fake = Faker()
@@ -157,6 +158,21 @@ class ThreadEventFactory(factory.django.DjangoModelFactory):
author = factory.SubFactory(UserFactory)
class UserEventFactory(factory.django.DjangoModelFactory):
"""A factory to create user events for testing purposes."""
class Meta:
model = models.UserEvent
user = factory.SubFactory(UserFactory)
thread = factory.SubFactory(ThreadFactory)
thread_event = factory.SubFactory(
ThreadEventFactory,
thread=factory.SelfAttribute("..thread"),
)
type = UserEventTypeChoices.MENTION
class ContactFactory(factory.django.DjangoModelFactory):
"""A factory to random contacts for testing purposes."""

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.11 on 2026-04-09 14:41
import core.enums
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_channel_encrypted_settings_scope_level'),
]
operations = [
migrations.AlterField(
model_name='threadevent',
name='type',
field=models.CharField(choices=core.enums.thread_event_type_choices, max_length=36, verbose_name='type'),
),
migrations.CreateModel(
name='UserEvent',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('type', models.CharField(choices=core.enums.user_event_type_choices, max_length=36, verbose_name='type')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='read at')),
('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to='core.thread')),
('thread_event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to='core.threadevent')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user event',
'verbose_name_plural': 'user events',
'db_table': 'messages_userevent',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'type', 'read_at'], name='usrevt_user_type_read'), models.Index(fields=['thread', 'type'], name='usrevt_thread_type')],
'constraints': [models.UniqueConstraint(fields=('user', 'thread_event', 'type'), name='usrevt_user_event_type_uniq')],
},
),
]

View File

@@ -48,6 +48,8 @@ from core.enums import (
ThreadAccessRoleChoices,
ThreadEventTypeChoices,
UserAbilities,
thread_event_type_choices,
user_event_type_choices,
)
from core.mda.rfc5322 import EmailParseError, parse_email_message
from core.mda.signing import generate_dkim_key as _generate_dkim_key
@@ -1567,7 +1569,7 @@ class ThreadEvent(BaseModel):
type = models.CharField(
"type",
max_length=36,
choices=ThreadEventTypeChoices.choices,
choices=thread_event_type_choices,
)
channel = models.ForeignKey(
"Channel",
@@ -1614,6 +1616,88 @@ class ThreadEvent(BaseModel):
raise ValidationError({"data": exception.message}) from exception
super().clean()
def is_editable(self):
"""Return whether the event can still be edited or deleted.
The time window is controlled by ``settings.MAX_THREAD_EVENT_EDIT_DELAY``
(in seconds). A value of 0 disables the restriction.
"""
delay = settings.MAX_THREAD_EVENT_EDIT_DELAY
if not delay:
return True
return timezone.now() - self.created_at <= timedelta(seconds=delay)
class UserEvent(BaseModel):
"""User event model to track user-specific events like mentions and assignments.
Semantics: a UserEvent is a *global* notification for the (user, thread)
pair. It is intentionally **not** scoped to a specific mailbox: a user who
accesses the same thread through multiple mailboxes sees the notification
in each of them, because the notification targets the user, not a given
access path. This is why there is no FK to ``Mailbox`` / ``ThreadAccess``.
Any future ``UserEventTypeChoices`` that would carry a mailbox-specific
context (e.g. quota or delivery failure on a given BAL) does **not** fit
this model as-is and should either introduce nullable discriminators keyed
on ``type``, or live in a separate model.
Note: ``thread`` is denormalized from ``thread_event.thread`` so that the
``Exists(...)`` annotations used by ThreadViewSet.get_queryset() can filter
on the thread FK directly, avoiding an extra JOIN on every thread list.
"""
user = models.ForeignKey(
"User",
on_delete=models.CASCADE,
related_name="user_events",
)
thread = models.ForeignKey(
"Thread",
on_delete=models.CASCADE,
related_name="user_events",
)
thread_event = models.ForeignKey(
"ThreadEvent",
on_delete=models.CASCADE,
related_name="user_events",
)
type = models.CharField(
"type",
max_length=36,
choices=user_event_type_choices,
)
read_at = models.DateTimeField(
"read at",
null=True,
blank=True,
)
class Meta:
db_table = "messages_userevent"
verbose_name = "user event"
verbose_name_plural = "user events"
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["user", "thread_event", "type"],
name="usrevt_user_event_type_uniq",
),
]
indexes = [
models.Index(
fields=["user", "type", "read_at"],
name="usrevt_user_type_read",
),
models.Index(
fields=["thread", "type"],
name="usrevt_thread_type",
),
]
def __str__(self):
return f"{self.user} - {self.type} - {self.thread} - {self.created_at}"
class Contact(BaseModel):
"""Contact model to store contact information."""

View File

@@ -2,14 +2,14 @@
# pylint: disable=unused-argument
import logging
import uuid
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from core import models
from core.enums import ChannelScopeLevel
from core import enums, models
from core.services.identity.keycloak import (
sync_mailbox_to_keycloak_user,
sync_maildomain_to_keycloak_group,
@@ -309,5 +309,157 @@ def delete_user_scope_channels_on_user_delete(sender, instance, **kwargs):
"""
models.Channel.objects.filter(
user=instance,
scope_level=ChannelScopeLevel.USER,
scope_level=enums.ChannelScopeLevel.USER,
).delete()
def _validate_user_ids_with_access(thread_event, thread, users_data):
"""Validate and deduplicate user IDs, checking ThreadAccess.
Shared validation logic for mentions. Parses UUIDs from
the 'id' field of each entry, deduplicates, and batch-validates that each
user has access to the thread via the MailboxAccess -> ThreadAccess chain.
Args:
thread_event: The ThreadEvent instance (for logging context).
thread: The Thread instance.
users_data: List of dicts with 'id' and 'name' keys.
Returns:
Set of valid user UUIDs that have access to the thread.
"""
if not users_data:
return set()
seen_user_ids = set()
unique_user_ids = []
for entry in users_data:
raw_id = entry.get("id")
if not raw_id:
continue
try:
user_id = uuid.UUID(raw_id)
except (ValueError, AttributeError):
logger.warning(
"Skipping user with invalid UUID '%s' in ThreadEvent %s",
raw_id,
thread_event.id,
)
continue
if user_id not in seen_user_ids:
seen_user_ids.add(user_id)
unique_user_ids.append(user_id)
if not unique_user_ids:
return set()
# Batch validate: users who have access to this thread
# Chain: User -> MailboxAccess.user -> MailboxAccess.mailbox -> ThreadAccess.mailbox
valid_user_ids = set(
models.ThreadAccess.objects.filter(
thread=thread,
mailbox__accesses__user_id__in=unique_user_ids,
).values_list("mailbox__accesses__user_id", flat=True)
)
for user_id in unique_user_ids:
if user_id not in valid_user_ids:
logger.warning(
"Skipping user %s in ThreadEvent %s: "
"user not found or no thread access",
user_id,
thread_event.id,
)
return valid_user_ids
def sync_mention_user_events(thread_event, thread, mentions_data):
"""Sync UserEvent MENTION records to match the current mentions payload.
Diffs the currently mentioned users against the existing UserEvent MENTION
records for this ThreadEvent and reconciles the two:
- Creates UserEvent records for newly mentioned users.
- Deletes UserEvent records for users who are no longer mentioned so that
stale entries do not linger in the "Mentioned" folder after an edit.
- Leaves existing records untouched when the user is still mentioned, which
preserves their ``read_at`` state across edits.
Invalid or unauthorized mentions are silently skipped with a warning log.
Args:
thread_event: The ThreadEvent instance containing mentions.
thread: The Thread instance.
mentions_data: List of mention dicts with 'id' and 'name' keys.
"""
new_valid_user_ids = _validate_user_ids_with_access(
thread_event, thread, mentions_data
)
existing_user_ids = set(
models.UserEvent.objects.filter(
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
).values_list("user_id", flat=True)
)
to_add = new_valid_user_ids - existing_user_ids
to_remove = existing_user_ids - new_valid_user_ids
if to_remove:
deleted_count, _ = models.UserEvent.objects.filter(
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
user_id__in=to_remove,
).delete()
if deleted_count:
logger.info(
"Deleted %d UserEvent MENTION(s) for ThreadEvent %s",
deleted_count,
thread_event.id,
)
if to_add:
user_events = [
models.UserEvent(
user_id=user_id,
thread=thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
for user_id in to_add
]
# ignore_conflicts=True lets the UniqueConstraint on
# (user, thread_event, type) absorb races between concurrent
# post_save signals on the same ThreadEvent (e.g. two PATCH in flight).
models.UserEvent.objects.bulk_create(user_events, ignore_conflicts=True)
logger.info(
"Created %d UserEvent MENTION(s) for ThreadEvent %s",
len(user_events),
thread_event.id,
)
@receiver(post_save, sender=models.ThreadEvent)
def handle_thread_event_post_save(sender, instance, created, **kwargs):
"""Handle post-save signal for ThreadEvent to sync UserEvent records.
Dispatches by ThreadEvent type:
- IM: Syncs UserEvent MENTION records on both create and update so that
edits to the mentions list add/remove notifications accordingly.
"""
try:
if instance.type == enums.ThreadEventTypeChoices.IM:
sync_mention_user_events(
thread_event=instance,
thread=instance.thread,
mentions_data=(instance.data or {}).get("mentions", []),
)
# pylint: disable=broad-exception-caught
except Exception as e:
logger.exception(
"Error in ThreadEvent post_save handler for event %s: %s",
instance.id,
e,
)

View File

@@ -119,6 +119,7 @@ class TestMailboxViewSet:
assert response.data[0]["count_unread_threads"] == 1
assert response.data[0]["count_threads"] == 2
assert response.data[0]["count_delivering"] == 1
assert response.data[0]["count_unread_mentions"] == 0
assert response.data[1]["id"] == str(user_mailbox1.id)
assert response.data[1]["email"] == str(user_mailbox1)
@@ -127,6 +128,7 @@ class TestMailboxViewSet:
assert response.data[1]["count_unread_threads"] == 1
assert response.data[1]["count_threads"] == 1
assert response.data[1]["count_delivering"] == 1
assert response.data[1]["count_unread_mentions"] == 0
def test_list_is_identity_false(self):
"""A mailbox that is not an identity should return is_identity=False."""
@@ -342,6 +344,114 @@ class TestMailboxViewSet:
assert response.data["count_unread_threads"] == 1
assert response.data["count_threads"] == 1
assert response.data["count_delivering"] == 1
assert response.data["count_unread_mentions"] == 0
def test_list_count_unread_mentions(self):
"""count_unread_mentions should count distinct threads with an active
unread MENTION UserEvent for the current user, scoped per mailbox."""
user = factories.UserFactory()
other_user = factories.UserFactory()
mailbox_a = factories.MailboxFactory()
mailbox_b = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox_a, user=user, role=models.MailboxRoleChoices.EDITOR
)
factories.MailboxAccessFactory(
mailbox=mailbox_b, user=user, role=models.MailboxRoleChoices.EDITOR
)
# Thread in mailbox_a with one unread mention for `user`
thread_a1 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_a,
thread=thread_a1,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event_a1 = factories.ThreadEventFactory(thread=thread_a1, author=other_user)
factories.UserEventFactory(
user=user,
thread=thread_a1,
thread_event=event_a1,
type=enums.UserEventTypeChoices.MENTION,
)
# Thread in mailbox_a with two unread mentions for `user` — still counted once
thread_a2 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_a,
thread=thread_a2,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event_a2a = factories.ThreadEventFactory(thread=thread_a2, author=other_user)
event_a2b = factories.ThreadEventFactory(thread=thread_a2, author=other_user)
factories.UserEventFactory(
user=user,
thread=thread_a2,
thread_event=event_a2a,
type=enums.UserEventTypeChoices.MENTION,
)
factories.UserEventFactory(
user=user,
thread=thread_a2,
thread_event=event_a2b,
type=enums.UserEventTypeChoices.MENTION,
)
# Thread in mailbox_a with a mention already read — must be excluded
thread_a3 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_a,
thread=thread_a3,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event_a3 = factories.ThreadEventFactory(thread=thread_a3, author=other_user)
factories.UserEventFactory(
user=user,
thread=thread_a3,
thread_event=event_a3,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
# Thread in mailbox_a mentioning someone else — must be excluded
thread_a5 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_a,
thread=thread_a5,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event_a5 = factories.ThreadEventFactory(thread=thread_a5, author=user)
factories.UserEventFactory(
user=other_user,
thread=thread_a5,
thread_event=event_a5,
type=enums.UserEventTypeChoices.MENTION,
)
# Thread in mailbox_b with one unread mention — scoping check
thread_b1 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox_b,
thread=thread_b1,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event_b1 = factories.ThreadEventFactory(thread=thread_b1, author=other_user)
factories.UserEventFactory(
user=user,
thread=thread_b1,
thread_event=event_b1,
type=enums.UserEventTypeChoices.MENTION,
)
client = APIClient()
client.force_authenticate(user=user)
response = client.get(reverse("mailboxes-list"))
assert response.status_code == status.HTTP_200_OK
by_id = {m["id"]: m for m in response.data}
assert by_id[str(mailbox_a.id)]["count_unread_mentions"] == 2
assert by_id[str(mailbox_b.id)]["count_unread_mentions"] == 1
def test_retrieve_mailbox_unauthorized(self):
"""Test that users cannot retrieve mailboxes they don't have access to."""

View File

@@ -1,8 +1,11 @@
"""Tests for the ThreadEvent API endpoints."""
import uuid
from datetime import timedelta
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
import pytest
from rest_framework import status
@@ -12,6 +15,12 @@ from core import enums, factories, models
pytestmark = pytest.mark.django_db
def _force_created_at(event, created_at):
"""Bypass ``auto_now_add`` to set a past ``created_at`` on an event."""
models.ThreadEvent.objects.filter(pk=event.pk).update(created_at=created_at)
event.refresh_from_db()
def get_thread_event_url(thread_id, event_id=None):
"""Helper function to get the thread event URL."""
if event_id:
@@ -228,6 +237,162 @@ class TestThreadEventUpdate:
assert event.type == "im"
def _grant_thread_access(thread):
"""Create a user with edit access to ``thread`` so they can be mentioned."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
return user
class TestThreadEventMentionSyncOnUpdate:
"""Test that editing an IM ThreadEvent syncs UserEvent MENTION records."""
def test_edit_adds_new_mention(self, api_client):
"""Adding a mention on edit should create a UserEvent MENTION."""
author, _mailbox, thread = setup_user_with_thread_access()
alice = _grant_thread_access(thread)
api_client.force_authenticate(user=author)
event = factories.ThreadEventFactory(
thread=thread,
author=author,
type=enums.ThreadEventTypeChoices.IM,
data={"content": "Hello"},
)
assert not models.UserEvent.objects.filter(thread_event=event).exists()
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{
"data": {
"content": "Hello @[Alice]",
"mentions": [{"id": str(alice.id), "name": "Alice"}],
}
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_events = models.UserEvent.objects.filter(
thread_event=event, type=enums.UserEventTypeChoices.MENTION
)
assert user_events.count() == 1
assert user_events.first().user_id == alice.id
def test_edit_removes_dropped_mention(self, api_client):
"""Removing a mention on edit should delete the stale UserEvent MENTION."""
author, _mailbox, thread = setup_user_with_thread_access()
alice = _grant_thread_access(thread)
api_client.force_authenticate(user=author)
event = factories.ThreadEventFactory(
thread=thread,
author=author,
type=enums.ThreadEventTypeChoices.IM,
data={
"content": "Hey @[Alice]",
"mentions": [{"id": str(alice.id), "name": "Alice"}],
},
)
# Signal created the UserEvent on save.
assert models.UserEvent.objects.filter(thread_event=event, user=alice).exists()
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "Never mind"}},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert not models.UserEvent.objects.filter(
thread_event=event, type=enums.UserEventTypeChoices.MENTION
).exists()
def test_edit_preserves_read_at_for_unchanged_mention(self, api_client):
"""Editing content without touching a mention must preserve read_at."""
author, _mailbox, thread = setup_user_with_thread_access()
alice = _grant_thread_access(thread)
api_client.force_authenticate(user=author)
event = factories.ThreadEventFactory(
thread=thread,
author=author,
type=enums.ThreadEventTypeChoices.IM,
data={
"content": "Hey @[Alice]",
"mentions": [{"id": str(alice.id), "name": "Alice"}],
},
)
user_event = models.UserEvent.objects.get(
thread_event=event, user=alice, type=enums.UserEventTypeChoices.MENTION
)
# Simulate Alice having already read the mention.
read_at = timezone.now()
user_event.read_at = read_at
user_event.save()
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{
"data": {
"content": "Hey @[Alice], updated",
"mentions": [{"id": str(alice.id), "name": "Alice"}],
}
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
user_event.refresh_from_db()
assert user_event.read_at == read_at
def test_edit_swaps_mentioned_user(self, api_client):
"""Replacing a mention should delete the old UserEvent and create the new one."""
author, _mailbox, thread = setup_user_with_thread_access()
alice = _grant_thread_access(thread)
bob = _grant_thread_access(thread)
api_client.force_authenticate(user=author)
event = factories.ThreadEventFactory(
thread=thread,
author=author,
type=enums.ThreadEventTypeChoices.IM,
data={
"content": "Hey @[Alice]",
"mentions": [{"id": str(alice.id), "name": "Alice"}],
},
)
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{
"data": {
"content": "Hey @[Bob]",
"mentions": [{"id": str(bob.id), "name": "Bob"}],
}
},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mention_user_ids = set(
models.UserEvent.objects.filter(
thread_event=event, type=enums.UserEventTypeChoices.MENTION
).values_list("user_id", flat=True)
)
assert mention_user_ids == {bob.id}
class TestThreadEventDelete:
"""Test the DELETE /threads/{thread_id}/events/{id}/ endpoint."""
@@ -258,6 +423,102 @@ class TestThreadEventDelete:
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestThreadEventEditDelay:
"""Test the MAX_THREAD_EVENT_EDIT_DELAY window on update and delete."""
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60)
def test_update_within_delay_allowed(self, api_client):
"""An event within the edit delay can still be updated."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "Edited"}},
format="json",
)
assert response.status_code == status.HTTP_200_OK
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60)
def test_update_after_delay_forbidden(self, api_client):
"""An event older than the edit delay cannot be updated."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
_force_created_at(event, timezone.now() - timedelta(hours=25))
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "Too late"}},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
event.refresh_from_db()
# Content must be unchanged
assert event.data.get("content") != "Too late"
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60)
def test_delete_after_delay_forbidden(self, api_client):
"""An event older than the edit delay cannot be deleted."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
_force_created_at(event, timezone.now() - timedelta(hours=25))
response = api_client.delete(get_thread_event_url(thread.id, event.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.ThreadEvent.objects.filter(pk=event.pk).exists()
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=0)
def test_update_when_delay_disabled(self, api_client):
"""A zero delay disables the restriction entirely."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
_force_created_at(event, timezone.now() - timedelta(days=365))
response = api_client.patch(
get_thread_event_url(thread.id, event.id),
{"data": {"content": "Still editable"}},
format="json",
)
assert response.status_code == status.HTTP_200_OK
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=0)
def test_delete_when_delay_disabled(self, api_client):
"""A zero delay allows deletion regardless of age."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
_force_created_at(event, timezone.now() - timedelta(days=365))
response = api_client.delete(get_thread_event_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
@override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60)
def test_is_editable_field_in_response(self, api_client):
"""``is_editable`` reflects the current delay state in the payload."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
fresh = factories.ThreadEventFactory(thread=thread, author=user)
stale = factories.ThreadEventFactory(thread=thread, author=user)
_force_created_at(stale, timezone.now() - timedelta(hours=25))
response = api_client.get(get_thread_event_url(thread.id))
assert response.status_code == status.HTTP_200_OK
by_id = {item["id"]: item for item in response.data}
assert by_id[str(fresh.id)]["is_editable"] is True
assert by_id[str(stale.id)]["is_editable"] is False
class TestThreadEventDataValidation:
"""Test that the data field is validated against the JSON schema for each event type."""
@@ -326,3 +587,129 @@ class TestThreadEventDataValidation:
response = api_client.post(get_thread_event_url(thread.id), data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "data" in response.data
def get_read_mention_url(thread_id, event_id):
"""Helper to build the read-mention URL for a thread event."""
return reverse(
"thread-event-read-mention",
kwargs={"thread_id": thread_id, "id": event_id},
)
class TestThreadEventReadMention:
"""Test the PATCH /threads/{thread_id}/events/{id}/read-mention/ endpoint."""
def test_read_mention_success(self, api_client):
"""PATCH marks the current user's unread MENTION on the event as read."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
user_event = factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
user_event.refresh_from_db()
assert user_event.read_at is not None
def test_read_mention_viewer_access(self, api_client):
"""A viewer can acknowledge their own mention (no edit access required)."""
user, _mailbox, thread = setup_user_with_thread_access(
role=enums.ThreadAccessRoleChoices.VIEWER
)
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
user_event = factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
user_event.refresh_from_db()
assert user_event.read_at is not None
def test_read_mention_without_thread_access_forbidden(self, api_client):
"""PATCH without access to the thread is forbidden."""
user = factories.UserFactory()
api_client.force_authenticate(user=user)
_other_user, _mailbox, thread = setup_user_with_thread_access()
event = factories.ThreadEventFactory(thread=thread, author=_other_user)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_read_mention_does_not_affect_other_users(self, api_client):
"""PATCH must never touch other users' UserEvents."""
user, _mailbox, thread = setup_user_with_thread_access()
other_user = factories.UserFactory()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
other_ue = factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
other_ue.refresh_from_db()
assert other_ue.read_at is None
def test_read_mention_idempotent(self, api_client):
"""PATCH on an already-read mention is a no-op but still returns 204."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
original_read_at = event.created_at
user_event = factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
read_at=original_read_at,
)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
user_event.refresh_from_db()
assert user_event.read_at == original_read_at
def test_read_mention_non_existent_event(self, api_client):
"""PATCH on a non-existent event returns 404."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.patch(get_read_mention_url(thread.id, uuid.uuid4()))
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_read_mention_unauthenticated(self, api_client):
"""Unauthenticated request should be rejected."""
_user, _mailbox, thread = setup_user_with_thread_access()
event = factories.ThreadEventFactory(thread=thread)
response = api_client.patch(get_read_mention_url(thread.id, event.id))
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)

View File

@@ -0,0 +1,533 @@
"""Tests for has_mention and has_unread_mention filters and stats on Thread API."""
from django.urls import reverse
from django.utils import timezone
import pytest
from rest_framework import status
from core import enums, factories
pytestmark = pytest.mark.django_db
def setup_user_with_thread_access(role=enums.ThreadAccessRoleChoices.EDITOR):
"""Create a user with mailbox access and thread access."""
user = factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=role,
)
return user, mailbox, thread
class TestThreadFilterUnreadMention:
"""Test GET /api/v1.0/threads/?has_unread_mention=1 filter."""
def test_thread_mention_unread_filter_returns_matching(self, api_client):
"""Filter should return only threads with active unread MENTION UserEvents."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Create an unread mention on this thread
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
# Create another thread without mentions
thread_no_mention = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread_no_mention,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_unread_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
thread_ids = [t["id"] for t in response.data["results"]]
assert str(thread.id) in thread_ids
assert str(thread_no_mention.id) not in thread_ids
def test_thread_mention_unread_filter_empty_when_none(self, api_client):
"""Filter should return empty list when no unread mentions exist."""
user, mailbox, _thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_unread_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
def test_thread_mention_unread_filter_excludes_read(self, api_client):
"""A thread whose only MENTION UserEvent has ``read_at`` set must be excluded.
Guards the ``read_at__isnull=True`` clause in ``_has_unread_mention``:
without it, the filter would collapse to ``has_mention`` and leak
already-acknowledged mentions.
"""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Seed a mention that has already been read.
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_unread_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
thread_ids = [t["id"] for t in response.data["results"]]
assert str(thread.id) not in thread_ids
assert len(response.data["results"]) == 0
def test_thread_mention_unread_filter_ignores_other_user(self, api_client):
"""Mentions belonging to another user must not leak into the filter."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Another user has an unread mention on the same thread
other_user = factories.UserFactory()
event = factories.ThreadEventFactory(thread=thread, author=other_user)
factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_unread_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
class TestThreadStatsUnreadMention:
"""Test GET /api/v1.0/threads/stats/?stats_fields=has_unread_mention."""
def test_thread_mention_unread_stats_returns_count(self, api_client):
"""Stats should return correct has_unread_mention count."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Create unread mentions on two threads
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
thread2 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread2,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event2 = factories.ThreadEventFactory(thread=thread2, author=user)
factories.UserEventFactory(
user=user,
thread=thread2,
thread_event=event2,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_unread_mention",
},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["has_unread_mention"] == 2
def test_thread_mention_unread_stats_ignores_other_user(self, api_client):
"""Stats must not count MENTION UserEvents belonging to another user."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
other_user = factories.UserFactory()
event = factories.ThreadEventFactory(thread=thread, author=other_user)
factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_unread_mention",
},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["has_unread_mention"] == 0
class TestThreadEventHasUnreadMention:
"""Test GET /api/v1.0/threads/{id}/events/ returns has_unread_mention."""
def test_thread_mention_unread_event_flag_true(self, api_client):
"""ThreadEvent with unread mention for current user should have has_unread_mention=True."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("thread-event-list", kwargs={"thread_id": thread.id}),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["has_unread_mention"] is True
def test_thread_mention_unread_event_flag_false_no_mention(self, api_client):
"""ThreadEvent without mention should have has_unread_mention=False."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
factories.ThreadEventFactory(thread=thread, author=user)
response = api_client.get(
reverse("thread-event-list", kwargs={"thread_id": thread.id}),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["has_unread_mention"] is False
def test_thread_mention_unread_event_flag_false_when_read(self, api_client):
"""ThreadEvent with already-read mention should have has_unread_mention=False."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
response = api_client.get(
reverse("thread-event-list", kwargs={"thread_id": thread.id}),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["has_unread_mention"] is False
def test_thread_mention_unread_event_flag_ignores_other_user(self, api_client):
"""has_unread_mention must ignore mentions whose UserEvent targets another user."""
user, _mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
other_user = factories.UserFactory()
event = factories.ThreadEventFactory(thread=thread, author=other_user)
factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("thread-event-list", kwargs={"thread_id": thread.id}),
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 1
assert response.data[0]["has_unread_mention"] is False
class TestThreadFilterMention:
"""Test GET /api/v1.0/threads/?has_mention=1 filter.
Unlike has_unread_mention which only returns threads with unread mentions,
has_mention returns threads with ANY active mention (read or unread).
"""
def test_thread_mention_any_filter_returns_matching(self, api_client):
"""Filter should return threads with both read and unread mentions."""
user, mailbox, thread_unread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Thread with unread mention
event1 = factories.ThreadEventFactory(thread=thread_unread, author=user)
factories.UserEventFactory(
user=user,
thread=thread_unread,
thread_event=event1,
type=enums.UserEventTypeChoices.MENTION,
)
# Thread with read mention
thread_read = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread_read,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event2 = factories.ThreadEventFactory(thread=thread_read, author=user)
factories.UserEventFactory(
user=user,
thread=thread_read,
thread_event=event2,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
# Thread without any mention
thread_none = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread_none,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
thread_ids = [t["id"] for t in response.data["results"]]
assert str(thread_unread.id) in thread_ids
assert str(thread_read.id) in thread_ids
assert str(thread_none.id) not in thread_ids
def test_thread_mention_any_filter_empty_when_none(self, api_client):
"""Filter should not return threads without any MENTION UserEvent."""
user, mailbox, _thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
def test_thread_mention_any_filter_includes_read(self, api_client):
"""Filter should include threads where the mention has been read."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
event = factories.ThreadEventFactory(thread=thread, author=user)
factories.UserEventFactory(
user=user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
thread_ids = [t["id"] for t in response.data["results"]]
assert str(thread.id) in thread_ids
def test_thread_mention_any_filter_ignores_other_user(self, api_client):
"""Mentions belonging to another user must not leak into the filter."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
other_user = factories.UserFactory()
event = factories.ThreadEventFactory(thread=thread, author=other_user)
factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("threads-list"),
{"mailbox_id": str(mailbox.id), "has_mention": "1"},
)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
class TestThreadStatsMention:
"""Test GET /api/v1.0/threads/stats/?stats_fields=has_mention."""
def test_thread_mention_any_stats_returns_count(self, api_client):
"""Stats should count threads with any active mention (read or unread)."""
user, mailbox, thread1 = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
# Thread 1: unread mention
event1 = factories.ThreadEventFactory(thread=thread1, author=user)
factories.UserEventFactory(
user=user,
thread=thread1,
thread_event=event1,
type=enums.UserEventTypeChoices.MENTION,
)
# Thread 2: read mention
thread2 = factories.ThreadFactory()
factories.ThreadAccessFactory(
mailbox=mailbox,
thread=thread2,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
event2 = factories.ThreadEventFactory(thread=thread2, author=user)
factories.UserEventFactory(
user=user,
thread=thread2,
thread_event=event2,
type=enums.UserEventTypeChoices.MENTION,
read_at=timezone.now(),
)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_mention",
},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["has_mention"] == 2
def test_thread_mention_any_stats_ignores_other_user(self, api_client):
"""Stats must not count MENTION UserEvents belonging to another user."""
user, mailbox, thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
other_user = factories.UserFactory()
event = factories.ThreadEventFactory(thread=thread, author=other_user)
factories.UserEventFactory(
user=other_user,
thread=thread,
thread_event=event,
type=enums.UserEventTypeChoices.MENTION,
)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_mention",
},
)
assert response.status_code == status.HTTP_200_OK
assert response.data["has_mention"] == 0
class TestThreadStatsMentionUnreadSuffixValidation:
"""Test that mention-related fields reject the generic '_unread' suffix.
has_mention / has_unread_mention are annotation-only fields (no real model
column), and "unread" in has_unread_mention refers to UserEvent.read_at, not
ThreadAccess.read_at. Allowing the generic '_unread' suffix would both crash
at runtime (Q(has_mention=True) has no column) and be semantically misleading.
"""
def test_thread_mention_stats_rejects_has_mention_unread_suffix(self, api_client):
"""`has_mention_unread` must be rejected at validation with a 400."""
user, mailbox, _thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_mention_unread",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "has_mention_unread" in response.data["detail"]
def test_thread_mention_stats_rejects_has_unread_mention_unread_suffix(
self, api_client
):
"""`has_unread_mention_unread` must be rejected at validation with a 400."""
user, mailbox, _thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_unread_mention_unread",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "has_unread_mention_unread" in response.data["detail"]
def test_thread_mention_stats_rejects_mixed_suffix_validation(self, api_client):
"""A valid field next to an invalid mention unread combo must still 400.
Guards against a regression where one-off validation would let the
invalid field reach the aggregation loop because another valid field
was present in the same request.
"""
user, mailbox, _thread = setup_user_with_thread_access()
api_client.force_authenticate(user=user)
response = api_client.get(
reverse("threads-stats"),
{
"mailbox_id": str(mailbox.id),
"stats_fields": "has_unread_mention,has_mention_unread",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "has_mention_unread" in response.data["detail"]

View File

@@ -19,10 +19,12 @@ from core.factories import (
MailboxFactory,
MessageFactory,
ThreadAccessFactory,
ThreadEventFactory,
ThreadFactory,
UserEventFactory,
UserFactory,
)
from core.models import Thread, ThreadAccess
from core.models import Thread, ThreadAccess, ThreadEvent, UserEvent
pytestmark = pytest.mark.django_db
@@ -645,3 +647,167 @@ def test_split_thread_returns_new_thread_data(
assert "subject" in response.data
assert "messages" in response.data
assert "accesses" in response.data
# --- ThreadEvent / UserEvent split tests ---
def _force_event_created_at(event, created_at):
"""Bypass ``auto_now_add`` to pin a ThreadEvent at a specific timestamp."""
ThreadEvent.objects.filter(pk=event.pk).update(created_at=created_at)
event.refresh_from_db()
@patch("core.signals.reindex_thread_task")
@patch("core.signals.index_message_task")
def test_split_thread_moves_free_event_after_split_point(
_mock_index_msg, _mock_reindex_thread, api_client
):
"""A ThreadEvent without a message FK created at or after the split point
must follow the new thread, symmetric with how messages are split."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory()
thread, messages = _create_thread_with_messages(mailbox, count=3)
_setup_editor_access(user, mailbox, thread)
# Free event posted after messages[1] (the split point)
event_after = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(event_after, messages[1].created_at + timedelta(seconds=30))
# Free event posted before the split point — must stay on the old thread.
# NOTE: ``_create_thread_with_messages`` doesn't bypass ``auto_now_add`` so
# all messages[*].created_at are within microseconds of each other. We
# anchor offsets on messages[1].created_at (the split point) to guarantee
# strict ordering.
event_before = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(
event_before, messages[1].created_at - timedelta(seconds=30)
)
url = _get_split_url(thread.id)
response = api_client.post(url, {"message_id": str(messages[1].id)})
assert response.status_code == status.HTTP_201_CREATED
new_thread_id = response.data["id"]
event_after.refresh_from_db()
event_before.refresh_from_db()
assert str(event_after.thread_id) == new_thread_id
assert event_before.thread_id == thread.id
@patch("core.signals.reindex_thread_task")
@patch("core.signals.index_message_task")
def test_split_thread_moves_event_attached_to_moved_message(
_mock_index_msg, _mock_reindex_thread, api_client
):
"""A ThreadEvent attached to a moved message must follow its message, even
if its own created_at is earlier than the split point."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory()
thread, messages = _create_thread_with_messages(mailbox, count=3)
_setup_editor_access(user, mailbox, thread)
# Event attached to the split message itself, with a created_at BEFORE
# the split point — the FK must win over the timestamp.
event_on_moved = ThreadEventFactory(thread=thread, author=user, message=messages[1])
_force_event_created_at(event_on_moved, messages[0].created_at - timedelta(hours=1))
# Event attached to a message that stays — must not move.
event_on_staying = ThreadEventFactory(
thread=thread, author=user, message=messages[0]
)
_force_event_created_at(
event_on_staying, messages[2].created_at + timedelta(hours=1)
)
url = _get_split_url(thread.id)
response = api_client.post(url, {"message_id": str(messages[1].id)})
assert response.status_code == status.HTTP_201_CREATED
new_thread_id = response.data["id"]
event_on_moved.refresh_from_db()
event_on_staying.refresh_from_db()
assert str(event_on_moved.thread_id) == new_thread_id
assert event_on_staying.thread_id == thread.id
@patch("core.signals.reindex_thread_task")
@patch("core.signals.index_message_task")
def test_split_thread_moves_user_events_with_their_thread_event(
_mock_index_msg, _mock_reindex_thread, api_client
):
"""UserEvent.thread is denormalized from thread_event.thread. When an
event is moved, its UserEvents must be updated to keep the invariant."""
user = UserFactory()
mentioned = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory()
thread, messages = _create_thread_with_messages(mailbox, count=3)
_setup_editor_access(user, mailbox, thread)
event_after = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(event_after, messages[1].created_at + timedelta(seconds=30))
mention_after = UserEventFactory(
user=mentioned, thread=thread, thread_event=event_after
)
event_before = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(
event_before, messages[1].created_at - timedelta(seconds=30)
)
mention_before = UserEventFactory(
user=mentioned, thread=thread, thread_event=event_before
)
url = _get_split_url(thread.id)
response = api_client.post(url, {"message_id": str(messages[1].id)})
assert response.status_code == status.HTTP_201_CREATED
new_thread_id = response.data["id"]
mention_after.refresh_from_db()
mention_before.refresh_from_db()
assert str(mention_after.thread_id) == new_thread_id
assert mention_before.thread_id == thread.id
# Invariant: every UserEvent.thread matches its thread_event.thread.
for ue in UserEvent.objects.all():
assert ue.thread_id == ue.thread_event.thread_id
@patch("core.signals.reindex_thread_task")
@patch("core.signals.index_message_task")
def test_split_thread_events_are_counted_on_new_thread(
_mock_index_msg, _mock_reindex_thread, api_client
):
"""After split, events must be distributed so that counts add up on both
threads — guards against a regression where events would stay on the old
thread or be duplicated."""
user = UserFactory()
api_client.force_authenticate(user=user)
mailbox = MailboxFactory()
thread, messages = _create_thread_with_messages(mailbox, count=3)
_setup_editor_access(user, mailbox, thread)
# Two events before split, three after.
for i in range(2):
event = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(
event, messages[1].created_at - timedelta(seconds=i + 1)
)
for i in range(3):
event = ThreadEventFactory(thread=thread, author=user, message=None)
_force_event_created_at(
event, messages[1].created_at + timedelta(seconds=i + 1)
)
url = _get_split_url(thread.id)
response = api_client.post(url, {"message_id": str(messages[1].id)})
assert response.status_code == status.HTTP_201_CREATED
new_thread_id = response.data["id"]
assert ThreadEvent.objects.filter(thread=thread).count() == 2
assert ThreadEvent.objects.filter(thread_id=new_thread_id).count() == 3

View File

@@ -20,6 +20,7 @@ from core.factories import (
MessageFactory,
MessageRecipientFactory,
ThreadAccessFactory,
ThreadEventFactory,
ThreadFactory,
UserFactory,
)
@@ -1685,3 +1686,93 @@ class TestThreadListAPI:
)
assert response.status_code == status.HTTP_403_FORBIDDEN
class TestThreadListEventsCount:
"""Test that ThreadSerializer exposes events_count on the list endpoint.
This count drives the frontend refetch-on-new-event effect in MailboxProvider.
"""
@pytest.fixture
def url(self):
"""Return the URL for the list endpoint."""
return reverse("threads-list")
@staticmethod
def _setup_user_with_thread(user=None):
"""Create a user with an admin mailbox and an editor thread access."""
user = user or UserFactory()
mailbox = MailboxFactory()
MailboxAccessFactory(
mailbox=mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
thread = ThreadFactory()
ThreadAccessFactory(
mailbox=mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
return user, mailbox, thread
def test_list_threads_events_count_zero_when_no_events(self, api_client, url):
"""A thread without any ThreadEvent should expose events_count == 0."""
user, mailbox, thread = self._setup_user_with_thread()
api_client.force_authenticate(user=user)
response = api_client.get(url, {"mailbox_id": str(mailbox.id)})
assert response.status_code == status.HTTP_200_OK
payload = next(t for t in response.data["results"] if t["id"] == str(thread.id))
assert payload["events_count"] == 0
def test_list_threads_events_count_matches_thread_events(self, api_client, url):
"""events_count should equal the number of ThreadEvents attached to the thread."""
user, mailbox, thread = self._setup_user_with_thread()
api_client.force_authenticate(user=user)
ThreadEventFactory(thread=thread, author=user)
ThreadEventFactory(thread=thread, author=user)
ThreadEventFactory(thread=thread, author=user)
response = api_client.get(url, {"mailbox_id": str(mailbox.id)})
assert response.status_code == status.HTTP_200_OK
payload = next(t for t in response.data["results"] if t["id"] == str(thread.id))
assert payload["events_count"] == 3
def test_list_threads_events_count_distinct_per_thread(self, api_client, url):
"""events_count must use distinct counting to avoid JOIN multiplication.
The queryset joins on accesses__mailbox, so without ``distinct=True`` the
count would be multiplied by the number of ThreadAccess rows. Guard against
regressions by creating several accesses and expecting the raw event count.
"""
user, mailbox, thread = self._setup_user_with_thread()
api_client.force_authenticate(user=user)
# Add two extra accesses on the same thread from other mailboxes the user
# also has access to, to force multiple JOIN rows.
for _ in range(2):
extra_mailbox = MailboxFactory()
MailboxAccessFactory(
mailbox=extra_mailbox,
user=user,
role=enums.MailboxRoleChoices.ADMIN,
)
ThreadAccessFactory(
mailbox=extra_mailbox,
thread=thread,
role=enums.ThreadAccessRoleChoices.EDITOR,
)
ThreadEventFactory(thread=thread, author=user)
ThreadEventFactory(thread=thread, author=user)
response = api_client.get(url, {"mailbox_id": str(mailbox.id)})
assert response.status_code == status.HTTP_200_OK
payload = next(t for t in response.data["results"] if t["id"] == str(thread.id))
assert payload["events_count"] == 2

View File

@@ -2,6 +2,8 @@
from unittest import mock
from django.db.models import F
import pytest
USER = "user"
@@ -18,6 +20,43 @@ def mock_user_teams():
yield mock_teams
@pytest.fixture(autouse=True)
def _assert_user_event_thread_invariant(request):
"""Turn every DB-enabled test into a sentinel for the UserEvent invariant.
``UserEvent.thread`` is a denormalization of ``UserEvent.thread_event.thread``
(see the model docstring at ``core/models.py``). The denormalization exists
for query-plan reasons — the ``Exists(...)`` annotations that power the
mention filters in ``ThreadViewSet.get_queryset`` rely on filtering
``UserEvent`` by ``thread`` directly without a JOIN on ``ThreadEvent``.
That makes the ``thread_id == thread_event.thread_id`` equality a hard
invariant: any divergence silently corrupts the mention UX.
Python writes through ``save()`` don't help here because the hot path uses
``bulk_create`` (mention signal) and plain ``update()`` (thread split).
Rather than duplicate a check on every call site, we let the test suite
catch regressions by scanning for violators after each DB-enabled test.
The query is a single index-friendly ``EXCLUDE`` with no JOIN amplification
so the overhead is negligible.
"""
yield
# Skip for tests that don't hit the database — avoids
# ``Database access not allowed`` errors on pure unit tests.
if not any(request.node.iter_markers("django_db")):
return
# Imported late so conftest import does not pull Django models before
# settings are configured.
from core.models import UserEvent # pylint: disable=import-outside-toplevel
violators = UserEvent.objects.exclude(thread_id=F("thread_event__thread_id"))
count = violators.count()
assert count == 0, (
f"UserEvent invariant broken: {count} row(s) where "
"thread_id != thread_event.thread_id"
)
# @pytest.fixture
# @pytest.mark.django_db
# def create_testdomain():

View File

@@ -0,0 +1,179 @@
"""Tests for UserEvent model."""
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
import pytest
from core import enums, factories
from core import models as core_models
@pytest.mark.django_db
class TestUserEvent:
"""Test the UserEvent model."""
def test_user_event_factory_creates_valid_instance(self):
"""UserEventFactory should create a valid UserEvent with all fields."""
user_event = factories.UserEventFactory()
assert user_event.id is not None
assert user_event.user is not None
assert user_event.thread is not None
assert user_event.thread_event is not None
assert user_event.type is not None
assert user_event.created_at is not None
assert user_event.updated_at is not None
def test_user_event_invalid_type_raises_validation_error(self):
"""UserEvent with an invalid type should raise ValidationError on save."""
with pytest.raises(ValidationError):
factories.UserEventFactory(type="invalid")
def test_user_event_multiple_same_type_for_same_user_thread_allowed(self):
"""Multiple UserEvent of the same type for the same (user, thread) are allowed."""
user = factories.UserFactory()
thread = factories.ThreadFactory()
thread_event_1 = factories.ThreadEventFactory(thread=thread)
thread_event_2 = factories.ThreadEventFactory(thread=thread)
event_1 = factories.UserEventFactory(
user=user, thread=thread, thread_event=thread_event_1
)
event_2 = factories.UserEventFactory(
user=user, thread=thread, thread_event=thread_event_2
)
assert event_1.id != event_2.id
assert event_1.user == event_2.user
assert event_1.thread == event_2.thread
assert event_1.type == event_2.type
def test_user_event_read_at_null_by_default(self):
"""UserEvent.read_at should be null by default."""
user_event = factories.UserEventFactory()
assert user_event.read_at is None
def test_user_event_str_representation(self):
"""UserEvent.__str__ should return the expected format."""
user_event = factories.UserEventFactory()
expected = (
f"{user_event.user} - {user_event.type} - "
f"{user_event.thread} - {user_event.created_at}"
)
assert str(user_event) == expected
def test_user_event_ordering_is_descending_created_at(self):
"""Default ordering should be ['-created_at'] (descending)."""
assert core_models.UserEvent._meta.ordering == ["-created_at"]
def test_user_event_cascade_delete_user(self):
"""Deleting a User should cascade-delete related UserEvents."""
user_event = factories.UserEventFactory()
user_id = user_event.user.id
user_event.user.delete()
assert not core_models.UserEvent.objects.filter(user_id=user_id).exists()
def test_user_event_cascade_delete_thread(self):
"""Deleting a Thread should cascade-delete related UserEvents."""
user_event = factories.UserEventFactory()
thread_id = user_event.thread.id
user_event.thread.delete()
assert not core_models.UserEvent.objects.filter(thread_id=thread_id).exists()
def test_user_event_cascade_delete_thread_event(self):
"""Deleting a ThreadEvent should cascade-delete related UserEvents."""
user_event = factories.UserEventFactory()
thread_event_id = user_event.thread_event.id
user_event.thread_event.delete()
assert not core_models.UserEvent.objects.filter(
thread_event_id=thread_event_id
).exists()
def test_user_event_duplicate_via_save_raises_validation_error(self):
"""The unique constraint must reject a duplicate via the normal save path.
``BaseModel.save()`` runs ``full_clean()``, so the duplicate is caught
by Django model validation before hitting the DB.
"""
user = factories.UserFactory()
thread_event = factories.ThreadEventFactory()
factories.UserEventFactory(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
with pytest.raises(ValidationError):
core_models.UserEvent.objects.create(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
def test_user_event_duplicate_via_bulk_create_raises_integrity_error(self):
"""The DB UniqueConstraint must reject duplicates on the bulk_create path.
``bulk_create`` bypasses ``full_clean()``, so this exercises the actual
DB-level constraint. This is the path used by ``sync_mention_user_events``
and it is what protects against races between two concurrent post_save
signals on the same ThreadEvent.
"""
user = factories.UserFactory()
thread_event = factories.ThreadEventFactory()
factories.UserEventFactory(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
with transaction.atomic(), pytest.raises(IntegrityError):
core_models.UserEvent.objects.bulk_create(
[
core_models.UserEvent(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
]
)
def test_user_event_bulk_create_ignore_conflicts_absorbs_duplicates(self):
"""``bulk_create(..., ignore_conflicts=True)`` must be idempotent.
This mirrors the signal behavior in ``sync_mention_user_events``: when
two concurrent flows try to insert the same (user, thread_event, type),
the second one must be silently absorbed by the UniqueConstraint.
"""
user = factories.UserFactory()
thread_event = factories.ThreadEventFactory()
factories.UserEventFactory(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
core_models.UserEvent.objects.bulk_create(
[
core_models.UserEvent(
user=user,
thread=thread_event.thread,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
)
],
ignore_conflicts=True,
)
assert (
core_models.UserEvent.objects.filter(
user=user,
thread_event=thread_event,
type=enums.UserEventTypeChoices.MENTION,
).count()
== 1
)

View File

@@ -5,6 +5,7 @@ This command creates demo users, mailboxes, shared mailboxes, and outbox test da
for E2E testing across different browsers (chromium, firefox, webkit).
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
@@ -15,6 +16,7 @@ from core.enums import (
MailDomainAccessRoleChoices,
MessageDeliveryStatusChoices,
ThreadAccessRoleChoices,
ThreadEventTypeChoices,
)
from core.services.identity.keycloak import get_keycloak_admin_client
@@ -142,7 +144,7 @@ class Command(BaseCommand):
# Step 8: Create shared mailbox thread data for IM testing
self.stdout.write("\n-- 7/7 💬 Creating shared mailbox thread for IM testing")
self._create_shared_mailbox_thread_data(shared_mailbox)
self._create_shared_mailbox_thread_data(shared_mailbox, regular_users)
def _create_user_with_mailbox(
self, email, domain, is_domain_admin=False, is_superuser=False
@@ -469,8 +471,14 @@ class Command(BaseCommand):
return thread
def _create_shared_mailbox_thread_data(self, shared_mailbox):
"""Create a thread in the shared mailbox for testing internal messages (IM)."""
def _create_shared_mailbox_thread_data(self, shared_mailbox, regular_users):
"""Create a thread in the shared mailbox for testing internal messages (IM).
Also seeds one pre-aged ThreadEvent per browser user so the
"edit delay elapsed" e2e test can assert on a message whose
`is_editable` has already flipped to false — no runtime ageing
required, keeps the test deterministic and fast.
"""
subject = "Shared inbox thread for IM"
# Clean up existing thread
@@ -509,6 +517,38 @@ class Command(BaseCommand):
is_draft=False,
)
# Seed one pre-aged IM ThreadEvent per browser user.
#
# The "edit delay elapsed" test needs a ThreadEvent whose
# `created_at` is older than `MAX_THREAD_EVENT_EDIT_DELAY`, so that
# `is_editable` returns false and the UI hides Edit/Delete actions.
#
# Each browser gets its own aged event authored by its own user so
# the `canModify = isAuthor && is_editable` check in the frontend
# exercises the `is_editable: false` branch (not the `isAuthor: false`
# one).
past = timezone.now() - timezone.timedelta(
seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY + 1
)
for user, _mailbox in regular_users:
# Extract "{browser}" from "user.e2e.{browser}@{domain}".
browser = user.email.split("@")[0].removeprefix("user.e2e.")
content = f"[e2e-aged-{browser}] Message past edit delay"
event = models.ThreadEvent.objects.create(
thread=thread,
type=ThreadEventTypeChoices.IM,
author=user,
data={"content": content},
)
# Bypass `auto_now` / `auto_now_add` with a raw UPDATE so the
# timestamps actually land in the past.
models.ThreadEvent.objects.filter(pk=event.pk).update(
created_at=past, updated_at=past
)
self.stdout.write(
self.style.SUCCESS(f" ✓ Seeded aged ThreadEvent for {user.email}")
)
thread.update_stats()
self.stdout.write(

View File

@@ -120,6 +120,16 @@ class Base(Configuration):
500, environ_name="MAX_RECIPIENTS_PER_MESSAGE", environ_prefix=None
)
# Thread events
# Time window (in seconds) during which a ThreadEvent can be edited or
# deleted after creation. Set to 0 to disable the restriction and allow
# edits indefinitely.
MAX_THREAD_EVENT_EDIT_DELAY = values.PositiveIntegerValue(
60 * 60, # 1 hour in seconds
environ_name="MAX_THREAD_EVENT_EDIT_DELAY",
environ_prefix=None,
)
# Throttling - limits external recipients per mailbox/maildomain per time period
# Format: "count/period" where period is minute, hour, or day. None to disable.
THROTTLE_MAILBOX_OUTBOUND_EXTERNAL_RECIPIENTS = ThrottleRateValue(

View File

@@ -1,7 +1,8 @@
import test, { expect, Page } from "@playwright/test";
import { resetDatabase, getMailboxEmail } from "../utils";
import { signInKeycloakIfNeeded } from "../utils-test";
import { signInKeycloakIfNeeded, inboxFolderLink } from "../utils-test";
import { BrowserName } from "../types";
import { API_URL } from "../constants";
/**
* Navigate to the shared mailbox and open the IM test thread.
@@ -20,7 +21,7 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) {
await page.waitForLoadState("networkidle");
// Navigate to inbox and open the IM thread
await page.getByRole("link", { name: /^inbox/i }).click();
await inboxFolderLink(page).click();
await page.waitForLoadState("networkidle");
await page
.getByRole("link", { name: "Shared inbox thread for IM" })
@@ -70,7 +71,7 @@ test.describe("Thread Events (Internal Messages)", () => {
}) => {
await page.waitForLoadState("networkidle");
await page.getByRole("link", { name: /^inbox/i }).click();
await inboxFolderLink(page).click();
await page.waitForLoadState("networkidle");
await page
@@ -353,7 +354,7 @@ test.describe("Thread Events (Internal Messages)", () => {
// Confirmation modal appears
const confirmModal = page.getByRole("dialog");
await expect(confirmModal).toBeVisible();
await expect(confirmModal.getByText("Delete message")).toBeVisible();
await expect(confirmModal.getByText("Delete internal comment")).toBeVisible();
// Confirm deletion
await confirmModal.getByRole("button", { name: "Delete" }).click();
@@ -389,4 +390,165 @@ test.describe("Thread Events (Internal Messages)", () => {
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
await expect(link).toHaveText("https://example.com");
});
test("should surface a new IM mention in the mailbox list and thread entry", async ({
page,
browserName,
}) => {
await navigateToSharedThread(page, browserName);
// Extract the thread ID from the URL we just landed on so we can POST
// directly against the thread-events endpoint.
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
const threadId = threadMatch?.[1];
expect(threadId, "thread id should be present in URL").toBeTruthy();
// Reuse the existing browser session cookies — they carry both the
// session id and the CSRF token expected by DRF on unsafe verbs.
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
// Fetch the current user so we can mention ourselves. The UI filters
// out self-mentions, but the backend allows them — POSTing directly is
// the simplest way to create an unread mention visible to the test user.
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
expect(meResponse.ok()).toBeTruthy();
const me = (await meResponse.json()) as { id: string; full_name: string };
// Create an IM mentioning the current user. sync_mention_user_events
// runs in the post_save signal and materialises the UserEvent MENTION
// record that feeds the "Mentioned" folder counter and the thread item
// badge.
const createResponse = await page.request.post(
`${API_URL}/api/v1.0/threads/${threadId}/events/`,
{
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
},
data: {
type: "im",
data: {
content: `@[${me.full_name}] please take a look`,
mentions: [{ id: me.id, name: me.full_name }],
},
},
},
);
expect(createResponse.ok()).toBeTruthy();
const createdEvent = (await createResponse.json()) as { id: string };
// Sanity-check the backend state directly so that a later UI assertion
// failure unambiguously points at the frontend (stats query cache,
// wrong mailbox context, etc.) rather than a missing UserEvent.
const mailboxIdMatch = page.url().match(/\/mailbox\/([0-9a-f-]+)/i);
const mailboxId = mailboxIdMatch?.[1];
expect(mailboxId, "mailbox id should be present in URL").toBeTruthy();
const statsResponse = await page.request.get(
`${API_URL}/api/v1.0/threads/stats/`,
{
params: {
mailbox_id: mailboxId!,
stats_fields: "has_unread_mention",
has_active: "1",
has_mention: "1",
},
},
);
expect(statsResponse.ok()).toBeTruthy();
const stats = (await statsResponse.json()) as { has_unread_mention: number };
expect(stats.has_unread_mention).toBe(1);
// Click the "Refresh" button to force `refetchMailboxes()`. The returned
// mailbox has `count_unread_mentions=1` which trips the effect in
// MailboxProvider that invalidates both the threads list and the stats
// queries — the exact chain of cache updates we need for the UI
// assertions below to pick up the new mention.
//
// We deliberately avoid `page.goto`/`page.reload` here: full page loads
// hit a Next.js static-export hydration race in MailboxProvider that
// bounces shared-mailbox sessions back to the personal mailbox.
await page.getByRole("button", { name: "Refresh" }).click();
await page.waitForLoadState("networkidle");
// Primary UI assertion: the thread entry in the thread list shows the
// "Unread mention" badge. The thread list is re-fetched on navigation,
// which makes it the most reliable indicator that the mention landed.
const threadLink = page
.getByRole("link", { name: "Shared inbox thread for IM" })
.first();
await expect(
threadLink.getByLabel("Unread mention").first(),
).toBeVisible();
// Secondary assertion: the "Mentioned" folder counter in the sidebar.
// Scoped to `nav.mailbox-list` to disambiguate from any thread subject
// that might contain "Mentioned". Uses toContainText so trailing
// whitespace in the span does not fail the match.
const mentionedCounter = page
.locator("nav.mailbox-list .mailbox__item")
.filter({ hasText: "Mentioned" })
.locator(".mailbox__item-counter");
await expect(mentionedCounter).toContainText("1");
// Cleanup: delete the event we created so subsequent tests (and re-runs
// without db:reset) do not see a lingering unread mention on the thread.
const deleteResponse = await page.request.delete(
`${API_URL}/api/v1.0/threads/${threadId}/events/${createdEvent.id}/`,
{ headers: { "X-CSRFToken": csrfToken } },
);
expect(deleteResponse.ok()).toBeTruthy();
});
test("should hide edit and delete actions once the edit delay has elapsed", async ({
page,
browserName,
}) => {
await navigateToSharedThread(page, browserName);
// The e2e_demo management command seeds one pre-aged ThreadEvent per
// browser user with a browser-scoped content string. That event's
// `created_at` is pushed 2h into the past, so the backend's
// `is_editable` returns false and the frontend's `canModify` guard
// hides Edit/Delete. We locate the bubble by its unique content
// instead of sending a fresh message and ageing it at runtime.
const agedContent = `[e2e-aged-${browserName}] Message past edit delay`;
const agedBubble = page
.locator(".thread-event--im")
.filter({ hasText: agedContent });
await expect(agedBubble).toBeVisible();
// Hover to try and reveal actions — they must remain hidden because
// `canModify` in the component is false.
await agedBubble.locator(".thread-event__bubble").hover();
await expect(
agedBubble.getByRole("button", { name: "Edit" }),
).toHaveCount(0);
await expect(
agedBubble.getByRole("button", { name: "Delete" }),
).toHaveCount(0);
// And the server must reject the write even if the button were forced:
// this guards against the UI hiding actions while the backend still
// allows edits (or vice versa).
const eventDomId = await agedBubble.getAttribute("id");
const eventId = eventDomId?.replace(/^thread-event-/, "");
expect(eventId, "event id should be present on aged bubble").toBeTruthy();
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
const threadId = threadMatch?.[1];
const updateResponse = await page.request.patch(
`${API_URL}/api/v1.0/threads/${threadId}/events/${eventId}/`,
{
headers: {
"Content-Type": "application/json",
"X-CSRFToken": csrfToken,
},
data: { data: { content: "tampered after delay" } },
},
);
expect(updateResponse.status()).toBe(403);
});
});

View File

@@ -1,6 +1,6 @@
import test, { expect } from "@playwright/test";
import { resetDatabase } from "../utils";
import { signInKeycloakIfNeeded } from "../utils-test";
import { signInKeycloakIfNeeded, inboxFolderLink } from "../utils-test";
test.describe("Thread starred", () => {
test.beforeAll(async () => {
@@ -107,7 +107,7 @@ test.describe("Thread read / unread", () => {
await page.waitForLoadState("networkidle");
// Navigate to inbox where received threads exist
await page.getByRole("link", { name: /^inbox/i }).click();
await inboxFolderLink(page).click();
await page.waitForLoadState("networkidle");
// Open the thread (the IntersectionObserver auto-marks messages as read)
@@ -146,7 +146,7 @@ test.describe("Thread read / unread", () => {
await page.waitForLoadState("networkidle");
// Navigate to inbox
await page.getByRole("link", { name: /^inbox/i }).click();
await inboxFolderLink(page).click();
await page.waitForLoadState("networkidle");
// Apply the unread filter first
@@ -196,7 +196,7 @@ test.describe("Thread read / unread", () => {
await page.waitForLoadState("networkidle");
// Navigate to inbox
await page.getByRole("link", { name: /^inbox/i }).click();
await inboxFolderLink(page).click();
await page.waitForLoadState("networkidle");
// Verify both threads are visible initially

View File

@@ -1,8 +1,23 @@
import { expect, Page } from "@playwright/test";
import { expect, Locator, Page } from "@playwright/test";
import { AUTHENTICATION_URL } from "./constants";
import { getStorageStatePath } from "./utils";
/**
* Locate the "Inbox" folder link inside the sidebar mailbox list.
*
* Scoped to `nav.mailbox-list` to disambiguate from thread entries whose
* subject contains "inbox" (e.g. "Shared inbox thread for IM"). Matches the
* "Inbox" label case-sensitively — the lowercase "inbox" string that
* appears in textContent from the Material Icons font ligature is skipped
* by the capital-I match.
*/
export const inboxFolderLink = (page: Page): Locator =>
page
.locator("nav.mailbox-list .mailbox__item")
.filter({ hasText: /Inbox/ })
.first();
export const signInKeycloakIfNeeded = async ({ page, username, navigateTo = "/" }: { page: Page, username: string, navigateTo?: string }) => {
// Set up response listener BEFORE navigation to avoid race condition
const meResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1.0/users/me/') && [200, 401].includes(response.status()));

View File

@@ -59,8 +59,7 @@
"vitest": "4.0.18"
},
"engines": {
"node": ">=22.0.0 <23.0.0",
"npm": ">=10.0.0"
"node": ">=22.0.0 <23.0.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.57.1",

View File

@@ -21,6 +21,8 @@
"{{count}} messages have been updated._other": "{{count}} messages have been updated.",
"{{count}} messages imported_one": "{{count}} message imported",
"{{count}} messages imported_other": "{{count}} messages imported",
"{{count}} messages mentioning you_one": "{{count}} message mentioning you",
"{{count}} messages mentioning you_other": "{{count}} messages mentioning you",
"{{count}} messages of this thread have been deleted._one": "{{count}} message of this thread has been deleted.",
"{{count}} messages of this thread have been deleted._other": "{{count}} messages of this thread have been deleted.",
"{{count}} minutes ago_one": "{{count}} minute ago",
@@ -31,12 +33,18 @@
"{{count}} occurrences_other": "{{count}} occurrences",
"{{count}} results_one": "{{count}} result",
"{{count}} results_other": "{{count}} results",
"{{count}} results mentioning you_one": "{{count}} result mentioning you",
"{{count}} results mentioning you_other": "{{count}} results mentioning you",
"{{count}} selected threads_one": "{{count}} selected thread",
"{{count}} selected threads_other": "{{count}} selected threads",
"{{count}} starred messages_one": "{{count}} starred message",
"{{count}} starred messages_other": "{{count}} starred messages",
"{{count}} starred messages mentioning you_one": "{{count}} starred message mentioning you",
"{{count}} starred messages mentioning you_other": "{{count}} starred messages mentioning you",
"{{count}} starred results_one": "{{count}} starred result",
"{{count}} starred results_other": "{{count}} starred results",
"{{count}} starred results mentioning you_one": "{{count}} starred result mentioning you",
"{{count}} starred results mentioning you_other": "{{count}} starred results mentioning you",
"{{count}} threads are now starred._one": "The thread is now starred.",
"{{count}} threads are now starred._other": "{{count}} threads are now starred.",
"{{count}} threads have been archived._one": "The thread has been archived.",
@@ -55,12 +63,20 @@
"{{count}} threads selected_other": "{{count}} threads selected",
"{{count}} unread messages_one": "{{count}} unread message",
"{{count}} unread messages_other": "{{count}} unread messages",
"{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you",
"{{count}} unread messages mentioning you_other": "{{count}} unread messages mentioning you",
"{{count}} unread results_one": "{{count}} unread result",
"{{count}} unread results_other": "{{count}} unread results",
"{{count}} unread results mentioning you_one": "{{count}} unread result mentioning you",
"{{count}} unread results mentioning you_other": "{{count}} unread results mentioning you",
"{{count}} unread starred messages_one": "{{count}} unread starred message",
"{{count}} unread starred messages_other": "{{count}} unread starred messages",
"{{count}} unread starred messages mentioning you_one": "{{count}} unread starred message mentioning you",
"{{count}} unread starred messages mentioning you_other": "{{count}} unread starred messages mentioning you",
"{{count}} unread starred results_one": "{{count}} unread starred result",
"{{count}} unread starred results_other": "{{count}} unread starred results",
"{{count}} unread starred results mentioning you_one": "{{count}} unread starred result mentioning you",
"{{count}} unread starred results mentioning you_other": "{{count}} unread starred results mentioning you",
"{{count}} weeks ago_one": "{{count}} week ago",
"{{count}} weeks ago_other": "{{count}} weeks ago",
"{{count}} years ago_one": "{{count}} year ago",
@@ -109,9 +125,9 @@
"Are you sure you want to delete this auto-reply? This action is irreversible!": "Are you sure you want to delete this auto-reply? This action is irreversible!",
"Are you sure you want to delete this draft? This action cannot be undone.": "Are you sure you want to delete this draft? This action cannot be undone.",
"Are you sure you want to delete this integration? This action is irreversible!": "Are you sure you want to delete this integration? This action is irreversible!",
"Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.",
"Are you sure you want to delete this label? This action is irreversible!": "Are you sure you want to delete this label? This action is irreversible!",
"Are you sure you want to delete this mailbox? This action is irreversible!": "Are you sure you want to delete this mailbox? This action is irreversible!",
"Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.",
"Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!",
"Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!",
"Are you sure you want to reset the password?": "Are you sure you want to reset the password?",
@@ -153,6 +169,7 @@
"Close the menu": "Close the menu",
"Close this thread": "Close this thread",
"Collapse": "Collapse",
"Collapse {{name}}": "Collapse {{name}}",
"Collapse all": "Collapse all",
"Color: ": "Color: ",
"Coming soon": "Coming soon",
@@ -199,9 +216,9 @@
"Delete auto-reply \"{{autoreply}}\"": "Delete auto-reply \"{{autoreply}}\"",
"Delete draft": "Delete draft",
"Delete integration \"{{name}}\"": "Delete integration \"{{name}}\"",
"Delete internal comment": "Delete internal comment",
"Delete label \"{{label}}\"": "Delete label \"{{label}}\"",
"Delete mailbox {{mailbox}}": "Delete mailbox {{mailbox}}",
"Delete message": "Delete message",
"Delete signature \"{{signature}}\"": "Delete signature \"{{signature}}\"",
"Delete template \"{{template}}\"": "Delete template \"{{template}}\"",
"Delivering": "Delivering",
@@ -262,6 +279,7 @@
"Every {{count}} years_one": "Every {{count}} years",
"Every {{count}} years_other": "Every {{count}} years",
"Expand": "Expand",
"Expand {{name}}": "Expand {{name}}",
"Expand all": "Expand all",
"Failed to delete auto-reply.": "Failed to delete auto-reply.",
"Failed to delete integration.": "Failed to delete integration.",
@@ -368,6 +386,7 @@
"Mark as read from here": "Mark as read from here",
"Mark as unread": "Mark as unread",
"Mark as unread from here": "Mark as unread from here",
"Mentioned": "Mentioned",
"Message content": "Message content",
"Message from {referer_domain}": "Message from {referer_domain}",
"Message sent successfully": "Message sent successfully",
@@ -586,6 +605,7 @@
"Unknown": "Unknown",
"Unknown user": "Unknown user",
"Unread": "Unread",
"Unread mention": "Unread mention",
"Unsaved changes": "Unsaved changes",
"Unstar": "Unstar",
"Unstar this thread": "Unstar this thread",

View File

@@ -32,6 +32,9 @@
"{{count}} messages imported_one": "{{count}} message importé",
"{{count}} messages imported_many": "{{count}} messages importés",
"{{count}} messages imported_other": "{{count}} messages importés",
"{{count}} messages mentioning you_one": "{{count}} message vous mentionnant",
"{{count}} messages mentioning you_many": "{{count}} messages vous mentionnant",
"{{count}} messages mentioning you_other": "{{count}} messages vous mentionnant",
"{{count}} messages of this thread have been deleted._one": "{{count}} message de cette conversation a été supprimé.",
"{{count}} messages of this thread have been deleted._many": "{{count}} messages de cette conversation ont été supprimés.",
"{{count}} messages of this thread have been deleted._other": "{{count}} messages de cette conversation ont été supprimés.",
@@ -47,15 +50,24 @@
"{{count}} results_one": "{{count}} résultat",
"{{count}} results_many": "{{count}} résultats",
"{{count}} results_other": "{{count}} résultats",
"{{count}} results mentioning you_one": "{{count}} résultat vous mentionnant",
"{{count}} results mentioning you_many": "{{count}} résultats vous mentionnant",
"{{count}} results mentioning you_other": "{{count}} résultats vous mentionnant",
"{{count}} selected threads_one": "{{count}} conversation sélectionnée",
"{{count}} selected threads_many": "{{count}} conversations sélectionnées",
"{{count}} selected threads_other": "{{count}} conversations sélectionnées",
"{{count}} starred messages_one": "{{count}} message suivi",
"{{count}} starred messages_many": "{{count}} messages suivis",
"{{count}} starred messages_other": "{{count}} messages suivis",
"{{count}} starred messages mentioning you_one": "{{count}} message suivi vous mentionnant",
"{{count}} starred messages mentioning you_many": "{{count}} messages suivis vous mentionnant",
"{{count}} starred messages mentioning you_other": "{{count}} messages suivis vous mentionnant",
"{{count}} starred results_one": "{{count}} résultat suivi",
"{{count}} starred results_many": "{{count}} résultats suivis",
"{{count}} starred results_other": "{{count}} résultats suivis",
"{{count}} starred results mentioning you_one": "{{count}} résultat suivi vous mentionnant",
"{{count}} starred results mentioning you_many": "{{count}} résultats suivis vous mentionnant",
"{{count}} starred results mentioning you_other": "{{count}} résultats suivis vous mentionnant",
"{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.",
"{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.",
"{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.",
@@ -83,15 +95,27 @@
"{{count}} unread messages_one": "{{count}} message non lu",
"{{count}} unread messages_many": "{{count}} messages non lus",
"{{count}} unread messages_other": "{{count}} messages non lus",
"{{count}} unread messages mentioning you_one": "{{count}} message non lu vous mentionnant",
"{{count}} unread messages mentioning you_many": "{{count}} messages non lus vous mentionnant",
"{{count}} unread messages mentioning you_other": "{{count}} messages non lus vous mentionnant",
"{{count}} unread results_one": "{{count}} résultat non lu",
"{{count}} unread results_many": "{{count}} résultats non lus",
"{{count}} unread results_other": "{{count}} résultats non lus",
"{{count}} unread results mentioning you_one": "{{count}} résultat non lu vous mentionnant",
"{{count}} unread results mentioning you_many": "{{count}} résultats non lus vous mentionnant",
"{{count}} unread results mentioning you_other": "{{count}} résultats non lus vous mentionnant",
"{{count}} unread starred messages_one": "{{count}} message suivi non lu",
"{{count}} unread starred messages_many": "{{count}} messages suivis non lus",
"{{count}} unread starred messages_other": "{{count}} messages suivis non lus",
"{{count}} unread starred messages mentioning you_one": "{{count}} message suivi non lu vous mentionnant",
"{{count}} unread starred messages mentioning you_many": "{{count}} messages suivis non lus vous mentionnant",
"{{count}} unread starred messages mentioning you_other": "{{count}} messages suivis non lus vous mentionnant",
"{{count}} unread starred results_one": "{{count}} résultat suivi non lu",
"{{count}} unread starred results_many": "{{count}} résultats suivis non lus",
"{{count}} unread starred results_other": "{{count}} résultats suivis non lus",
"{{count}} unread starred results mentioning you_one": "{{count}} résultat suivi non lu vous mentionnant",
"{{count}} unread starred results mentioning you_many": "{{count}} résultats suivis non lus vous mentionnant",
"{{count}} unread starred results mentioning you_other": "{{count}} résultats suivis non lus vous mentionnant",
"{{count}} weeks ago_one": "il y a {{count}} semaine",
"{{count}} weeks ago_many": "il y a {{count}} semaines",
"{{count}} weeks ago_other": "il y a {{count}} semaines",
@@ -143,9 +167,9 @@
"Are you sure you want to delete this auto-reply? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette réponse automatique ? Cette action est irréversible !",
"Are you sure you want to delete this draft? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce brouillon ? Cette action est irréversible.",
"Are you sure you want to delete this integration? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette intégration ? Cette action est irréversible !",
"Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce commentaire interne ? Il sera supprimé pour tous les utilisateurs. Cette action ne peut pas être annulée.",
"Are you sure you want to delete this label? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce libellé ? Cette action est irréversible !",
"Are you sure you want to delete this mailbox? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette boîte aux lettres ? Cette action est irréversible !",
"Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce message ? Il sera supprimé pour tous les utilisateurs. Cette action ne peut pas être annulée.",
"Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !",
"Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !",
"Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?",
@@ -187,6 +211,7 @@
"Close the menu": "Fermer le menu",
"Close this thread": "Fermer cette conversation",
"Collapse": "Réduire",
"Collapse {{name}}": "Réduire {{name}}",
"Collapse all": "Tout réduire",
"Color: ": "Couleur : ",
"Coming soon": "Bientôt disponible",
@@ -233,9 +258,9 @@
"Delete auto-reply \"{{autoreply}}\"": "Supprimer la réponse automatique \"{{autoreply}}\"",
"Delete draft": "Supprimer le brouillon",
"Delete integration \"{{name}}\"": "Supprimer l'intégration \"{{name}}\"",
"Delete internal comment": "Supprimer le commentaire interne",
"Delete label \"{{label}}\"": "Supprimer le libellé \"{{label}}\"",
"Delete mailbox {{mailbox}}": "Supprimer la boîte aux lettres {{mailbox}}",
"Delete message": "Supprimer le message",
"Delete signature \"{{signature}}\"": "Supprimer la signature \"{{signature}}\"",
"Delete template \"{{template}}\"": "Supprimer le modèle \"{{template}}\"",
"Delivering": "En cours d'envoi",
@@ -300,6 +325,7 @@
"Every {{count}} years_many": "Tous les {{count}} ans",
"Every {{count}} years_other": "Tous les {{count}} ans",
"Expand": "Développer",
"Expand {{name}}": "",
"Expand all": "Tout développer",
"Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.",
"Failed to delete integration.": "Erreur lors de la suppression de l'intégration.",
@@ -407,6 +433,7 @@
"Mark as read from here": "Marquer comme lu à partir d'ici",
"Mark as unread": "Marquer comme non lu",
"Mark as unread from here": "Marquer comme non lu à partir d'ici",
"Mentioned": "Mentionné",
"Message content": "Contenu du message",
"Message from {referer_domain}": "Message de {referer_domain}",
"Message sent successfully": "Message envoyé avec succès",
@@ -629,6 +656,7 @@
"Unknown": "Inconnu",
"Unknown user": "Utilisateur inconnu",
"Unread": "Non lu",
"Unread mention": "Mention non lue",
"Unsaved changes": "Modifications non enregistrées",
"Unstar": "Désactiver le suivi",
"Unstar this thread": "Ne plus suivre cette conversation",

View File

@@ -18,9 +18,14 @@ export interface Mailbox {
/** Whether this mailbox identifies a person (i.e. is not an alias or a group) */
readonly is_identity: boolean;
readonly role: MailboxRoleChoices;
readonly count_unread_threads: string;
readonly count_threads: string;
readonly count_delivering: string;
/** Return the number of threads with unread messages in the mailbox. */
readonly count_unread_threads: number;
/** Return the number of threads in the mailbox. */
readonly count_threads: number;
/** Return the number of threads with messages being delivered. */
readonly count_delivering: number;
/** Return the number of threads with unread mentions for the current user. */
readonly count_unread_mentions: number;
/** Instance permissions and capabilities */
readonly abilities: MailboxAbilities;
}

View File

@@ -20,6 +20,7 @@ export interface Thread {
readonly snippet: string;
readonly messages: string;
readonly has_unread: boolean;
readonly has_unread_mention: boolean;
readonly has_trashed: boolean;
/** Whether all messages in the thread are trashed */
readonly is_trashed: boolean;
@@ -69,4 +70,5 @@ export interface Thread {
readonly accesses: readonly ThreadAccessDetail[];
readonly labels: readonly ThreadLabel[];
readonly summary: string;
readonly events_count: number;
}

View File

@@ -30,6 +30,8 @@ export interface ThreadEvent {
message?: string | null;
readonly author: UserWithoutAbilities;
data: ThreadEventDataOneOf;
readonly has_unread_mention: boolean;
readonly is_editable: boolean;
/** date and time at which a record was created */
readonly created_at: string;
/** date and time at which a record was last updated */

View File

@@ -27,6 +27,10 @@ export type ThreadsListParams = {
* Filter threads with draft messages (1=true, 0=false).
*/
has_draft?: number;
/**
* Filter threads with any mention (read or unread) for the current user (1=true, 0=false).
*/
has_mention?: number;
/**
* Filter threads that have messages (1=true, 0=false).
*/
@@ -47,6 +51,10 @@ export type ThreadsListParams = {
* Filter threads with unread messages (1=true, 0=false). Requires mailbox_id.
*/
has_unread?: number;
/**
* Filter threads with unread mentions for the current user (1=true, 0=false).
*/
has_unread_mention?: number;
/**
* Filter threads that are spam (1=true, 0=false).
*/

View File

@@ -24,6 +24,10 @@ export type ThreadsStatsRetrieveParams = {
* Filter threads with draft messages (1=true, 0=false).
*/
has_draft?: number;
/**
* Filter threads with any mention (read or unread) for the current user (1=true, 0=false).
*/
has_mention?: number;
/**
* Filter threads with messages sent by the user (1=true, 0=false).
*/
@@ -36,6 +40,10 @@ export type ThreadsStatsRetrieveParams = {
* Filter threads that are trashed (1=true, 0=false).
*/
has_trashed?: number;
/**
* Filter threads with unread mentions for the current user (1=true, 0=false).
*/
has_unread_mention?: number;
/**
* Filter threads by label slug.
*/
@@ -52,7 +60,7 @@ export type ThreadsStatsRetrieveParams = {
* Comma-separated list of fields to aggregate.
Special values: 'all' (count all threads), 'all_unread' (count all unread threads).
Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,
has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages.
has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.
Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.
Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'
*/

View File

@@ -15,4 +15,6 @@ export const ThreadsStatsRetrieveStatsFields = {
all_unread: "all_unread",
has_delivery_failed: "has_delivery_failed",
has_delivery_pending: "has_delivery_pending",
has_mention: "has_mention",
has_unread_mention: "has_unread_mention",
} as const;

View File

@@ -823,3 +823,124 @@ export const useThreadsEventsDestroy = <
return useMutation(mutationOptions, queryClient);
};
/**
* Mark the current user's unread MENTION on this ThreadEvent as read.
Returns 204 even when no UserEvent matches (idempotent); the thread
event itself is resolved via the standard ``get_object`` lookup so a
missing event yields 404.
*/
export type threadsEventsReadMentionPartialUpdateResponse204 = {
data: void;
status: 204;
};
export type threadsEventsReadMentionPartialUpdateResponse404 = {
data: void;
status: 404;
};
export type threadsEventsReadMentionPartialUpdateResponseSuccess =
threadsEventsReadMentionPartialUpdateResponse204 & {
headers: Headers;
};
export type threadsEventsReadMentionPartialUpdateResponseError =
threadsEventsReadMentionPartialUpdateResponse404 & {
headers: Headers;
};
export type threadsEventsReadMentionPartialUpdateResponse =
| threadsEventsReadMentionPartialUpdateResponseSuccess
| threadsEventsReadMentionPartialUpdateResponseError;
export const getThreadsEventsReadMentionPartialUpdateUrl = (
threadId: string,
id: string,
) => {
return `/api/v1.0/threads/${threadId}/events/${id}/read-mention/`;
};
export const threadsEventsReadMentionPartialUpdate = async (
threadId: string,
id: string,
options?: RequestInit,
): Promise<threadsEventsReadMentionPartialUpdateResponse> => {
return fetchAPI<threadsEventsReadMentionPartialUpdateResponse>(
getThreadsEventsReadMentionPartialUpdateUrl(threadId, id),
{
...options,
method: "PATCH",
},
);
};
export const getThreadsEventsReadMentionPartialUpdateMutationOptions = <
TError = ErrorType<void>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>,
TError,
{ threadId: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
}): UseMutationOptions<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>,
TError,
{ threadId: string; id: string },
TContext
> => {
const mutationKey = ["threadsEventsReadMentionPartialUpdate"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>,
{ threadId: string; id: string }
> = (props) => {
const { threadId, id } = props ?? {};
return threadsEventsReadMentionPartialUpdate(threadId, id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type ThreadsEventsReadMentionPartialUpdateMutationResult = NonNullable<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>
>;
export type ThreadsEventsReadMentionPartialUpdateMutationError =
ErrorType<void>;
export const useThreadsEventsReadMentionPartialUpdate = <
TError = ErrorType<void>,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>,
TError,
{ threadId: string; id: string },
TContext
>;
request?: SecondParameter<typeof fetchAPI>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof threadsEventsReadMentionPartialUpdate>>,
TError,
{ threadId: string; id: string },
TContext
> => {
const mutationOptions =
getThreadsEventsReadMentionPartialUpdateMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@@ -21,6 +21,7 @@ export const MESSAGE_IMPORT_TASK_KEY = APP_STORAGE_PREFIX + "message-import-task
export const EXTERNAL_IMAGES_CONSENT_KEY = APP_STORAGE_PREFIX + "external-images-consent";
export const THREAD_SELECTED_FILTERS_KEY = APP_STORAGE_PREFIX + "thread-selected-filters";
export const SILENT_LOGIN_RETRY_KEY = APP_STORAGE_PREFIX + "silent-login-retry";
export const EXPANDED_FOLDERS_KEY = APP_STORAGE_PREFIX + "expanded-folders";
// Enums

View File

@@ -1,4 +1,4 @@
import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
import { ALL_MESSAGES_FOLDER, MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
import { SearchHelper } from "@/features/utils/search-helper";
import { Label } from "@gouvfr-lasuite/ui-kit";
import { Button, Checkbox, Input, Select } from "@gouvfr-lasuite/cunningham-react";
@@ -74,7 +74,7 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) =
value={parsedQuery.in as string ?? 'all_messages'}
showLabelWhenSelected={false}
onChange={handleChange}
options={MAILBOX_FOLDERS().filter((folder) => folder.searchable).map((folder) => ({
options={[ALL_MESSAGES_FOLDER(), ...MAILBOX_FOLDERS().filter((folder) => folder.searchable)].map((folder) => ({
label: t(folder.name),
render: () => <FolderOption label={t(folder.name)} icon={folder.icon} />,
value: folder.id

View File

@@ -4,7 +4,7 @@
display: flex;
align-items: center;
gap: var(--c--globals--spacings--2xs);
padding-block: var(--c--globals--spacings--xs);
padding-block: var(--c--globals--spacings--3xs);
padding-inline: var(--c--globals--spacings--3xs);
color: inherit;
font-weight: 500;
@@ -39,9 +39,6 @@
transform: translateY(-50%);
width: var(--label-toggle-size);
height: var(--label-toggle-size);
& .c__button__icon > span {
font-size: var(--c--globals--font--sizes--h3);
}
}
.label-item__column:last-child {
@@ -75,9 +72,9 @@
min-width: 0;
.label-item__icon {
border: 2px solid var(--c--contextuals--border--semantic--contextual--primary);
width: 0.875rem;
height: 0.875rem;
border: 1px solid var(--c--contextuals--border--semantic--contextual--primary);
width: 0.75rem;
height: 0.75rem;
border-radius: 4px;
box-sizing: border-box;
padding: 0;

View File

@@ -1,6 +1,6 @@
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
import { DropdownMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox";
import { DropdownMenu, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
import clsx from "clsx";
import Link from "next/link";
@@ -44,7 +44,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
label_slug: label.slug
}, {
query: {
queryKey: ['threads', 'stats', selectedMailbox!.id, queryParams],
queryKey: getThreadsStatsQueryKey(selectedMailbox!.id, queryParams),
}
});
const unreadCount = (stats?.data as ThreadsStatsRetrieve200)?.all_unread ?? 0;
@@ -270,7 +270,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
className='label-item__toggle'
aria-expanded={isFolded}
title={isFolded ? t('Collapse') : t('Expand')}
icon={<Icon name={isFolded ? "chevron_right" : "expand_more"} />}
icon={<Icon name={isFolded ? "chevron_right" : "expand_more"} size={IconSize.MEDIUM} />}
aria-label={isFolded ? t('Expand') : t('Collapse')}
/>
)}

View File

@@ -6,6 +6,34 @@
flex-shrink: 0;
}
// Wraps a folder link together with its disclosure chevron button. The
// two stay visually attached (one hover background spanning both) but the
// DOM keeps them as siblings so interactive content is not nested inside
// the anchor.
.mailbox__item-row {
display: flex;
flex-direction: row;
align-items: stretch;
border-radius: 4px;
&:has(.mailbox__item--drag-over),
&:has(.mailbox__item:not(.mailbox__item--active):hover),
&:has(.mailbox__item:not(.mailbox__item--active):focus),
&:has(.mailbox__item-chevron:hover),
&:has(.mailbox__item-chevron:focus-visible),
&:has(.mailbox__item--active) {
background-color: var(--c--contextuals--background--semantic--contextual--primary);
& > .mailbox__item {
background: none;
}
}
.mailbox__item {
flex: 1;
min-width: 0;
}
}
.mailbox__item {
color: inherit;
display: flex;
@@ -14,11 +42,19 @@
align-items: center;
width: auto;
padding-inline: var(--c--globals--spacings--xs);
padding-block: var(--c--globals--font--sizes--t);
padding-block: var(--c--globals--spacings--3xs);
min-height: 2rem;
border-radius: 4px;
text-decoration: none;
}
// When the folder has a disclosure chevron, the button sits flush to the
// left of the link — remove the link's left padding so the label lines up
// where the chevron used to live and children keep their existing indent.
.mailbox__item--with-chevron {
padding-inline-start: 0;
}
.mailbox__item--drag-over,
.mailbox__item:not(.mailbox__item--active):hover,
.mailbox__item:not(.mailbox__item--active):focus {
@@ -61,3 +97,38 @@
color: var(--c--contextuals--content--semantic--error--tertiary);
font-size: var(--c--globals--font--sizes--xl);
}
// Native <button> acting as the disclosure control. Reset the browser
// defaults (background/border/padding) so it blends into the row while
// keeping the inherited focus ring for keyboard users.
.mailbox__item-chevron {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease;
font-size: var(--c--globals--font--sizes--lg);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
background: transparent;
border: none;
padding-inline: var(--c--globals--spacings--4xs);
border-radius: 4px;
&--collapsed {
transform: rotate(-90deg);
}
}
.mailbox__children {
display: flex;
flex-direction: column;
overflow: hidden;
}
.mailbox__children--collapsed {
display: none;
}
.mailbox__item--child {
padding-inline-start: 2.125rem; // 34px
}

View File

@@ -1,12 +1,12 @@
import { ThreadsStatsRetrieve200, ThreadsStatsRetrieveStatsFields, useThreadsStatsRetrieve } from "@/features/api/gen"
import { useMailboxContext } from "@/features/providers/mailbox"
import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox"
import clsx from "clsx"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { useMemo, useState } from "react"
import { useLayoutContext } from "../../../main"
import { useTranslation } from "react-i18next"
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
import i18n from "@/features/i18n/initI18n";
import useArchive from "@/features/message/use-archive";
import useTrash from "@/features/message/use-trash";
@@ -15,7 +15,7 @@ import { handle } from "@/features/utils/errors";
import ViewHelper from "@/features/utils/view-helper";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import { Tooltip } from "@gouvfr-lasuite/cunningham-react"
import { THREAD_PANEL_FILTER_PARAMS } from "../../../thread-panel/components/thread-panel-filter"
import { EXPANDED_FOLDERS_KEY } from "@/features/config/constants"
// @TODO: replace with real data when folder will be ready
type Folder = {
@@ -26,6 +26,7 @@ type Folder = {
showStats: boolean;
searchable?: boolean;
conditional?: boolean;
children?: Folder[];
}
export const MAILBOX_FOLDERS = () => [
@@ -38,17 +39,42 @@ export const MAILBOX_FOLDERS = () => [
filter: {
has_active: "1"
},
},
children: [
{
id: "all_messages",
name: i18n.t("All messages"),
icon: "mark_as_unread",
searchable: true,
id: "unread",
name: i18n.t("Unread"),
icon: "mark_email_unread",
searchable: false,
showStats: true,
filter: {
has_messages: "1"
has_active: "1",
has_unread: "1",
},
},
{
id: "starred",
name: i18n.t("Starred"),
icon: "star",
searchable: false,
showStats: true,
filter: {
has_active: "1",
has_starred: "1",
},
},
{
id: "mentioned",
name: i18n.t("Mentioned"),
icon: "alternate_email",
searchable: false,
showStats: true,
filter: {
has_active: "1",
has_mention: "1",
},
},
],
},
{
id: "drafts",
name: i18n.t("Drafts"),
@@ -112,7 +138,23 @@ export const MAILBOX_FOLDERS = () => [
has_trashed: "1",
},
},
] as const satisfies readonly Folder[];
] as const;
/**
* Virtual "All messages" folder. Not displayed in the sidebar — it represents
* the absence of any folder filter ("show everything"). Exposed separately so
* that the search filters form (and any view that needs the concept) can
* reference it without polluting MAILBOX_FOLDERS with a non-sidebar entry.
*/
export const ALL_MESSAGES_FOLDER = () => ({
id: "all_messages" as const,
name: i18n.t("All messages"),
icon: "mark_as_unread",
showStats: true,
filter: {
has_messages: "1",
},
});
/**
* Combines multiple stats fields into a comma-separated string for the API.
@@ -128,29 +170,94 @@ const combineStatsFields = (
return fields.join(',') as ThreadsStatsRetrieveStatsFields;
};
/**
* Finds the root folder in the MAILBOX_FOLDERS tree structure whose match
* (either directly or through one of its children) satisfies the predicate.
* A child match still returns the root folder so consumers always get the
* top-level entry (e.g. "Inbox" rather than "Unread").
*/
export const findRootFolder = (predicate: (folder: Folder) => boolean): Folder | undefined => {
for (const folder of MAILBOX_FOLDERS() as readonly Folder[]) {
if (predicate(folder)) return folder;
if (folder.children?.some(predicate)) return folder;
}
return undefined;
};
export const MailboxList = () => {
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
if (typeof window === 'undefined') return { 'inbox': true };
const savedState = localStorage.getItem(EXPANDED_FOLDERS_KEY);
if (savedState === null) return { 'inbox': true };
return JSON.parse(savedState) as Record<string, boolean>;
});
/**
* Toggle the expanded state of a folder and save the state to localStorage.
*/
const toggleFolder = (folderId: string) => {
setExpandedFolders((prev) => {
const nextState = {
...prev,
[folderId]: !prev[folderId],
};
if (typeof window !== 'undefined') {
localStorage.setItem(EXPANDED_FOLDERS_KEY, JSON.stringify(nextState));
}
return nextState;
});
};
return (
<nav className="mailbox-list">
{MAILBOX_FOLDERS().map((folder) => (
{(MAILBOX_FOLDERS() as readonly Folder[]).map((folder) => (
<div key={folder.id}>
<FolderItem
key={folder.icon}
folder={folder}
hasChildren={!!folder.children?.length}
isExpanded={!!expandedFolders[folder.id]}
onToggleExpand={() => toggleFolder(folder.id)}
childrenContainerId={`mailbox-children-${folder.id}`}
/>
{folder.children && (
<div
id={`mailbox-children-${folder.id}`}
className={clsx("mailbox__children", {
"mailbox__children--collapsed": !expandedFolders[folder.id],
})}
>
{folder.children.map((child) => (
<FolderItem
key={child.id}
folder={child}
isChild
/>
))}
</div>
)}
</div>
))}
</nav>
)
}
type FolderItemProps = {
folder: Folder
folder: Folder;
isChild?: boolean;
hasChildren?: boolean;
isExpanded?: boolean;
onToggleExpand?: () => void;
// Id of the children container the expand/collapse button toggles.
// Used as the target of `aria-controls` so screen readers can follow
// the disclosure relationship.
childrenContainerId?: string;
}
// Folders that accept thread drops
const DROPPABLE_FOLDER_IDS = ['inbox', 'archives', 'spam', 'trash'] as const;
type DroppableFolderId = typeof DROPPABLE_FOLDER_IDS[number];
const FolderItem = ({ folder }: FolderItemProps) => {
const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand, childrenContainerId }: FolderItemProps) => {
const { t } = useTranslation();
const { selectedMailbox } = useMailboxContext();
const { closeLeftPanel } = useLayoutContext();
@@ -169,6 +276,7 @@ const FolderItem = ({ folder }: FolderItemProps) => {
const stats_fields = useMemo(() => {
if (folder.id === 'drafts') return ThreadsStatsRetrieveStatsFields.all;
if (folder.id === 'outbox') return ThreadsStatsRetrieveStatsFields.all;
if (folder.id === 'mentioned') return ThreadsStatsRetrieveStatsFields.has_unread_mention;
return ThreadsStatsRetrieveStatsFields.all_unread;
}, [folder.id]);
const { data } = useThreadsStatsRetrieve({
@@ -180,7 +288,7 @@ const FolderItem = ({ folder }: FolderItemProps) => {
}, {
query: {
enabled: folder.showStats,
queryKey: ['threads', 'stats', selectedMailbox!.id, queryParams],
queryKey: getThreadsStatsQueryKey(selectedMailbox!.id, queryParams),
}
});
@@ -215,18 +323,18 @@ const FolderItem = ({ folder }: FolderItemProps) => {
}
}, [folder.id, isArchivedView, isSpamView, isTrashedView, isDraftsView, isSentView]);
const isActive = useMemo(() => {
const folderFilter = Object.entries(folder.filter || {});
// Exclude thread panel filter params from comparison so filters don't break folder matching
const folderParamsSize = Array.from(searchParams.keys()).filter(
(key) => !THREAD_PANEL_FILTER_PARAMS.includes(key as (typeof THREAD_PANEL_FILTER_PARAMS)[number])
).length;
if (folderFilter.length !== folderParamsSize) return false;
const isFolderActive = (folder: Folder): boolean => {
if (hasChildren === true && isExpanded) {
const hasChildrenActive = folder.children?.some((child) => isFolderActive(child)) ?? false;
if (hasChildrenActive) return false;
}
const folderFilter = Object.entries(folder.filter || {});
return folderFilter.every(([key, value]) => {
return searchParams.get(key) === value;
});
}, [searchParams, folder.filter]);
};
const isActive = isFolderActive(folder);
const folderCount = folderStats?.[stats_fields] ?? 0;
const hasDeliveryFailed = (folderStats?.[ThreadsStatsRetrieveStatsFields.has_delivery_failed] ?? 0) > 0;
@@ -322,33 +430,35 @@ const FolderItem = ({ folder }: FolderItemProps) => {
}
};
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
closeLeftPanel();
// Prevent navigation if already on this folder
if (isActive) {
e.preventDefault();
}
};
if (folder.conditional && folderCount === 0) {
return null;
}
return (
// Disclosure button label. Including the folder name gives the button
// a self-standing accessible name (e.g. "Expand Inbox") instead of a
// bare "Expand" which would force screen reader users to rely on the
// preceding link context to understand what is being toggled.
const chevronLabel = isExpanded
? t("Collapse {{name}}", { name: t(folder.name) })
: t("Expand {{name}}", { name: t(folder.name) });
const link = (
<Link
href={`/mailbox/${selectedMailbox?.id}?${queryParams}`}
onClick={handleClick}
onClick={closeLeftPanel}
shallow={false}
className={clsx("mailbox__item", {
"mailbox__item--active": isActive,
"mailbox__item--drag-over": isDragOver
"mailbox__item--drag-over": isDragOver,
"mailbox__item--child": isChild,
"mailbox__item--with-chevron": hasChildren,
})}
onDragOver={isDroppable ? handleDragOver : undefined}
onDragLeave={isDroppable ? handleDragLeave : undefined}
onDrop={isDroppable ? handleDrop : undefined}
>
<p className="mailbox__item-label">
<Icon name={folder.icon} type={IconType.OUTLINED} aria-hidden="true" />
<Icon name={folder.icon} type={IconType.OUTLINED} aria-hidden="true" size={IconSize.SMALL} />
{t(folder.name)}
</p>
<div className="mailbox__item__metadata">
@@ -358,5 +468,30 @@ const FolderItem = ({ folder }: FolderItemProps) => {
}
</div>
</Link>
);
if (!hasChildren) {
return link;
}
// Wrap the link and the disclosure button as siblings so the two
// interactive controls stay accessible (interactive content cannot be
// nested inside an <a>) and the link's accessible name stays clean.
return (
<div className="mailbox__item-row">
<button
type="button"
className={clsx("mailbox__item-chevron", {
"mailbox__item-chevron--collapsed": !isExpanded,
})}
onClick={onToggleExpand}
aria-expanded={isExpanded}
aria-controls={childrenContainerId}
aria-label={chevronLabel}
>
<Icon name="expand_more" type={IconType.OUTLINED} aria-hidden="true" />
</button>
{link}
</div>
)
}

View File

@@ -47,6 +47,10 @@
.thread-panel__header--count {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1 1 auto;
}
}
}
@@ -56,6 +60,8 @@
flex-direction: row;
align-items: center;
gap: var(--c--globals--spacings--4xs);
flex-shrink: 0;
margin-left: auto;
}
.thread-panel__threads_list {

View File

@@ -170,27 +170,81 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
type={IconType.FILLED}
name="mode_edit"
className="icon--size-sm"
aria-hidden="true"
/>
</Badge>
)}
{thread.has_attachments ? (
<Badge aria-label={t('Attachments')} title={t('Attachments')} color="neutral" variant="tertiary" compact>
<Icon name="attachment" size={IconSize.SMALL} />
<Badge
aria-label={t('Attachments')}
title={t('Attachments')}
color="neutral"
variant="tertiary"
compact>
<Icon
name="attachment"
size={IconSize.SMALL}
aria-hidden="true"
/>
</Badge>
) : null}
{thread.has_starred && (
<Badge
aria-label={t('Starred')}
title={t('Starred')}
color="yellow"
variant="tertiary"
compact>
<Icon
name="star"
size={IconSize.SMALL}
aria-hidden="true"
/>
</Badge>
)}
{thread.has_unread_mention && (
<Badge
aria-label={t('Unread mention')}
title={t('Unread mention')}
color="warning"
variant="tertiary"
compact>
<Icon
type={IconType.OUTLINED}
name="alternate_email"
aria-hidden="true"
className="icon--size-sm"
/>
</Badge>
)}
{thread.has_delivery_failed && (
<Badge aria-label={t('Delivery failed')} title={t('Some recipients have not received this message!')} color="error" variant="tertiary" compact>
<Icon name="error" type={IconType.OUTLINED} size={IconSize.SMALL} />
<Badge
aria-label={t('Delivery failed')}
title={t('Some recipients have not received this message!')}
color="error"
variant="tertiary"
compact>
<Icon
name="error"
type={IconType.OUTLINED}
size={IconSize.SMALL}
aria-hidden="true"
/>
</Badge>
)}
{!thread.has_delivery_failed && thread.has_delivery_pending && (
<Badge aria-label={t('Delivering')} title={t('This message has not yet been delivered to all recipients.')} color="warning" variant="tertiary" compact>
<Icon name="update" type={IconType.OUTLINED} size={IconSize.SMALL} />
</Badge>
)}
{thread.has_starred && (
<Badge aria-label={t('Starred')} title={t('Starred')} color="yellow" variant="tertiary" compact>
<Icon name="star" size={IconSize.SMALL} />
<Badge
aria-label={t('Delivering')}
title={t('This message has not yet been delivered to all recipients.')}
color="warning"
variant="tertiary"
compact>
<Icon
name="update"
type={IconType.OUTLINED}
size={IconSize.SMALL}
aria-hidden="true"
/>
</Badge>
)}
</div>

View File

@@ -1,70 +1,56 @@
import { useSearchParams } from "next/navigation";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react";
import { ContextMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit";
import { THREAD_SELECTED_FILTERS_KEY } from "@/features/config/constants";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useSafeRouterPush } from "@/hooks/use-safe-router-push";
import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
export const THREAD_PANEL_FILTER_PARAMS = [
"has_unread",
"has_starred",
] as const;
export type FilterType = (typeof THREAD_PANEL_FILTER_PARAMS)[number];
import {
DEFAULT_SELECTED_FILTERS,
THREAD_PANEL_FILTER_PARAMS,
useThreadPanelFilters,
type FilterType,
} from "../hooks/use-thread-panel-filters";
const getStoredSelectedFilters = (): FilterType[] => {
try {
const stored = JSON.parse(
localStorage.getItem(THREAD_SELECTED_FILTERS_KEY) ?? "[]",
);
if (
Array.isArray(stored) &&
stored.length > 0 &&
stored.every((s: string) => THREAD_PANEL_FILTER_PARAMS.includes(s as FilterType))
) {
return stored;
if (Array.isArray(stored) && stored.length > 0) {
const validFilters = stored.filter(
(value): value is FilterType =>
typeof value === "string" &&
THREAD_PANEL_FILTER_PARAMS.includes(value as FilterType),
);
if (validFilters.length > 0) {
return validFilters;
}
}
} catch {
// ignore
}
return ["has_unread"];
return DEFAULT_SELECTED_FILTERS;
};
export const ThreadPanelFilter = () => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const safePush = useSafeRouterPush();
const [selectedFilters, setSelectedFilters] =
useState<FilterType[]>(getStoredSelectedFilters);
const { threads } = useMailboxContext();
const { hasActiveFilters, activeFilters } = useThreadPanelFilters();
const { hasActiveFilters, activeFilters, applyFilters, clearFilters } =
useThreadPanelFilters();
const isDisabled = !threads?.results.length && !hasActiveFilters;
const filterLabels: Record<FilterType, string> = useMemo(
() => ({
has_unread: t("Unread"),
has_starred: t("Starred"),
has_mention: t("Mentioned"),
}),
[t],
);
const applyFilters = (filters: FilterType[]) => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
filters.forEach((filter) => params.set(filter, "1"));
safePush(params);
};
const clearFilters = () => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
safePush(params);
};
const handleToggleClick = () => {
if (hasActiveFilters) {
clearFilters();
@@ -77,7 +63,7 @@ export const ThreadPanelFilter = () => {
const toggled = selectedFilters.includes(type)
? selectedFilters.filter((f) => f !== type)
: [...selectedFilters, type];
const next = toggled.length > 0 ? toggled : ["has_unread"] as FilterType[];
const next = toggled.length > 0 ? toggled : DEFAULT_SELECTED_FILTERS;
setSelectedFilters(next);
localStorage.setItem(THREAD_SELECTED_FILTERS_KEY, JSON.stringify(next));
if (hasActiveFilters) {

View File

@@ -1,5 +1,5 @@
import { useSearchParams } from "next/navigation";
import { MAILBOX_FOLDERS } from "../../mailbox-panel/components/mailbox-list";
import { findRootFolder } from "../../mailbox-panel/components/mailbox-list";
import { useLabelsList } from "@/features/api/gen";
import { useMailboxContext } from "@/features/providers/mailbox";
import { useTranslation } from "react-i18next";
@@ -12,8 +12,8 @@ import useArchive from "@/features/message/use-archive";
import useSpam from "@/features/message/use-spam";
import useTrash from "@/features/message/use-trash";
import useStarred from "@/features/message/use-starred";
import { ThreadPanelFilter, THREAD_PANEL_FILTER_PARAMS } from "./thread-panel-filter";
import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
import { ThreadPanelFilter } from "./thread-panel-filter";
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
type ThreadPanelTitleProps = {
@@ -47,19 +47,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const isSentView = ViewHelper.isSentView();
const isDraftsView = ViewHelper.isDraftsView();
const { hasActiveFilters, activeFilters } = useThreadPanelFilters();
const folderSearchParams = useMemo(() => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
return params;
}, [searchParams]);
const { activeFilters } = useThreadPanelFilters();
const title = useMemo(() => {
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
if (searchParams.has('label_slug')) return (labelsQuery.data?.data || []).find((label) => label.slug === searchParams.get('label_slug'))?.name;
return MAILBOX_FOLDERS().find((folder) => new URLSearchParams(folder.filter).toString() === folderSearchParams.toString())?.name;
}, [searchParams, folderSearchParams, labelsQuery.data?.data, selectedMailbox, t])
// Thread panel filters stack on top of the folder filter — strip them
// so the matching resolves to the underlying folder.
const folderParams = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => folderParams.delete(param));
const activeFolder = findRootFolder((folder) => new URLSearchParams(folder.filter).toString() === folderParams.toString());
return activeFolder?.name;
}, [searchParams, labelsQuery.data?.data, selectedMailbox, t])
const handleSelectAllToggle = () => {
if (isAllSelected) {
@@ -96,6 +95,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
const unstarLabel = t('Unstar');
const countLabel = useMemo(() => {
if (isSearch) {
if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) {
return t('{{count}} unread starred results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread starred result mentioning you' });
}
if (activeFilters.has_mention && activeFilters.has_unread) {
return t('{{count}} unread results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread result mentioning you' });
}
if (activeFilters.has_mention && activeFilters.has_starred) {
return t('{{count}} starred results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} starred result mentioning you' });
}
if (activeFilters.has_mention) {
return t('{{count}} results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} result mentioning you' });
}
if (activeFilters.has_unread && activeFilters.has_starred) {
return t('{{count}} unread starred results', { count: threads?.count, defaultValue_one: '{{count}} unread starred result' });
}
@@ -108,6 +119,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
return t('{{count}} results', { count: threads?.count, defaultValue_one: '{{count}} result' });
}
else {
if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) {
return t('{{count}} unread starred messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread starred message mentioning you' });
}
if (activeFilters.has_mention && activeFilters.has_unread) {
return t('{{count}} unread messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread message mentioning you' });
}
if (activeFilters.has_mention && activeFilters.has_starred) {
return t('{{count}} starred messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} starred message mentioning you' });
}
if (activeFilters.has_mention) {
return t('{{count}} messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} message mentioning you' });
}
if (activeFilters.has_unread && activeFilters.has_starred) {
return t('{{count}} unread starred messages', { count: threads?.count, defaultValue_one: '{{count}} unread starred message' });
}
@@ -119,7 +142,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
}
return t('{{count}} messages', { count: threads?.count, defaultValue_one: '{{count}} message' });
}
}, [hasActiveFilters, activeFilters, isSearch, threads?.count, t]);
}, [activeFilters, isSearch, threads?.count, t]);
return (
<header className="thread-panel__header">
@@ -137,7 +160,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
className="thread-panel__header--checkbox"
/>
)}
<p className="thread-panel__header--count">
<p className="thread-panel__header--count" title={countLabel}>
{countLabel}
</p>
<div className="thread-panel__bar">

View File

@@ -1,11 +1,20 @@
import { useSearchParams } from "next/navigation";
import {
THREAD_PANEL_FILTER_PARAMS,
type FilterType,
} from "../components/thread-panel-filter";
import { useCallback } from "react";
import { useSafeRouterPush } from "@/hooks/use-safe-router-push";
export const THREAD_PANEL_FILTER_PARAMS = [
"has_unread",
"has_starred",
"has_mention",
] as const;
export type FilterType = (typeof THREAD_PANEL_FILTER_PARAMS)[number];
export const DEFAULT_SELECTED_FILTERS: FilterType[] = ["has_unread"];
export const useThreadPanelFilters = () => {
const searchParams = useSearchParams();
const safePush = useSafeRouterPush();
const activeFilters = THREAD_PANEL_FILTER_PARAMS.reduce(
(acc, param) => {
@@ -17,5 +26,21 @@ export const useThreadPanelFilters = () => {
const hasActiveFilters = Object.values(activeFilters).some(Boolean);
return { hasActiveFilters, activeFilters };
const applyFilters = useCallback(
(filters: FilterType[]) => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
filters.forEach((filter) => params.set(filter, "1"));
safePush(params);
},
[searchParams, safePush],
);
const clearFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
safePush(params);
}, [searchParams, safePush]);
return { hasActiveFilters, activeFilters, applyFilters, clearFilters };
};

View File

@@ -9,16 +9,13 @@ import { useSearchParams } from "next/navigation";
import ThreadPanelHeader from "./components/thread-panel-header";
import { useThreadSelection } from "@/features/providers/thread-selection";
import { useScrollRestore } from "@/features/providers/scroll-restore";
import { THREAD_PANEL_FILTER_PARAMS } from "./components/thread-panel-filter";
import { useSafeRouterPush } from "@/hooks/use-safe-router-push";
import { useThreadPanelFilters } from "./hooks/use-thread-panel-filters";
export const ThreadPanel = () => {
const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext();
const searchParams = useSearchParams();
const safePush = useSafeRouterPush();
const isSearch = searchParams.has('search');
const { hasActiveFilters } = useThreadPanelFilters();
const { hasActiveFilters, clearFilters } = useThreadPanelFilters();
const { t } = useTranslation();
const loaderRef = useRef<HTMLDivElement>(null);
const scrollContextKey = `${selectedMailbox?.id}:${searchParams.toString()}`;
@@ -74,12 +71,6 @@ export const ThreadPanel = () => {
);
}
const clearFilters = () => {
const params = new URLSearchParams(searchParams.toString());
THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param));
safePush(params);
};
const isEmpty = !threads?.results.length;
return (

View File

@@ -49,6 +49,15 @@
border-radius: var(--c--globals--spacings--3xs) var(--c--globals--spacings--base) var(--c--globals--spacings--base) var(--c--globals--spacings--base);
}
// Unread mention: a compact accessible badge (icon + text) sits in the
// header, pushed to the right. Keeping the indicator on the header — not
// on the bubble — avoids distracting from the message content itself.
.thread-event__mention-badge {
margin-left: auto;
gap: var(--c--globals--spacings--4xs);
transition: opacity 0.2s ease;
}
// Condensed: consecutive IMs from same author within 2 min
.thread-event--condensed {
margin-top: calc(-1 * var(--c--globals--spacings--xs));

View File

@@ -5,8 +5,10 @@ import { useThreadsEventsDestroy } from "@/features/api/gen/thread-events/thread
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
import { useMailboxContext } from "@/features/providers/mailbox";
import { AVATAR_COLORS, Icon, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit";
import { Badge } from "@/features/ui/components/badge";
import { AVATAR_COLORS, Icon, IconSize, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit";
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
import clsx from "clsx";
const TWO_MINUTES_MS = 2 * 60 * 1000;
@@ -24,9 +26,23 @@ const getAvatarColor = (name: string): string => {
type ThreadEventProps = {
event: ThreadEventType;
previousEvent?: ThreadEventType | null;
isCondensed?: boolean;
onEdit?: (event: ThreadEventType) => void;
onDelete?: (eventId: string) => void;
/**
* Ref setter wired by the parent when the event carries an unread mention,
* used by the IntersectionObserver that marks mentions as read on scroll.
* Receives the bubble element — the observer needs a target with a real
* bounding box to reliably report intersections.
*/
mentionRef?: (el: HTMLDivElement | null) => void;
/**
* True when this event OR any subsequent event condensed with it carries
* an unread mention for the current user. Drives the badge shown in the
* header. Computed by the parent so the first event of a condensed group
* surfaces mentions that would otherwise be hidden on condensed siblings.
*/
hasUnreadMention?: boolean;
};
const formatTime = (dateString: string) => {
@@ -43,7 +59,7 @@ const formatTime = (dateString: string) => {
* Returns true if this IM event should show a condensed view (no header),
* because the previous event is also an IM from the same author within 2 minutes.
*/
const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | null): boolean => {
export const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | null): boolean => {
if (!previousEvent) return false;
if (event.type !== ThreadEventTypeEnum.im || previousEvent.type !== ThreadEventTypeEnum.im) return false;
if (event.author?.id !== previousEvent.author?.id) return false;
@@ -57,15 +73,17 @@ const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | n
* Consecutive IMs from the same author within 2 minutes are condensed (no header).
* For other types: renders a minimal card with type badge and data.
*/
export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEventProps) => {
export const ThreadEvent = ({ event, isCondensed = false, onEdit, onDelete, mentionRef, hasUnreadMention = false }: ThreadEventProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const modals = useModals();
const { invalidateThreadEvents } = useMailboxContext();
const content = event.data?.content ?? "";
const condensed = isCondensed(event, previousEvent);
const isSender = event.author?.id === user?.id;
const isAuthor = event.author?.id === user?.id;
const isEdited = Math.abs(new Date(event.updated_at).getTime() - new Date(event.created_at).getTime()) > 1000;
// Edit/delete actions are only available to the author while the
// server-side edit delay has not elapsed (MAX_THREAD_EVENT_EDIT_DELAY).
const canModify = isAuthor && event.is_editable;
const deleteEvent = useThreadsEventsDestroy();
const [showActions, setShowActions] = useState(false);
@@ -108,8 +126,8 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv
const handleDelete = async () => {
const decision = await modals.deleteConfirmationModal({
title: <span className="c__modal__text--centered">{t('Delete message')}</span>,
children: t('Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.'),
title: <span className="c__modal__text--centered">{t('Delete internal comment')}</span>,
children: t('Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.'),
});
if (decision !== 'delete') return;
deleteEvent.mutate(
@@ -128,25 +146,34 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv
? event.data?.mentions?.map((m) => m.id)?.includes(user.id)
: false;
const imClasses = [
const imClasses = clsx(
"thread-event",
"thread-event--im",
condensed && "thread-event--condensed",
isSender && "thread-event--sender",
].filter(Boolean).join(" ");
{
"thread-event--condensed": isCondensed,
},
);
const bubbleStyle = {
"--thread-event-color": `var(--c--contextuals--background--palette--${avatarColor}--primary)`,
} as React.CSSProperties;
return (
<div className={imClasses}>
<div className={imClasses} id={`thread-event-${event.id}`}>
<div
ref={bubbleRef}
ref={(el) => {
// Combined ref: keeps the local bubbleRef (used for
// click-outside detection) and the parent-provided
// mentionRef (used by the IntersectionObserver that
// acknowledges unread mentions on scroll) in sync.
bubbleRef.current = el;
mentionRef?.(el);
}}
className={`thread-event__bubble${showActions ? " thread-event__bubble--actions-visible" : ""}`}
style={bubbleStyle}
data-event-id={event.id}
>
{!condensed && (
{!isCondensed && (
<div className="thread-event__header">
<span className="thread-event__author">
<UserAvatar fullName={event.author?.full_name || event.author?.email || t("Unknown")} size="xsmall" />
@@ -155,14 +182,31 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv
<span className="thread-event__time">
{formatTime(event.created_at)}
</span>
{hasUnreadMention && (
<Badge
color="warning"
variant="secondary"
compact
role="status"
className="thread-event__mention-badge"
>
<Icon
type={IconType.OUTLINED}
size={IconSize.X_SMALL}
name="alternate_email"
aria-hidden="true"
/>
{t('Unread mention')}
</Badge>
)}
</div>
)}
<div
className={`thread-event__content${pressing ? " thread-event__content--pressing" : ""}`}
onTouchStart={isSender ? handleTouchStart : undefined}
onTouchEnd={isSender ? cancelLongPress : undefined}
onTouchMove={isSender ? cancelLongPress : undefined}
onTouchCancel={isSender ? cancelLongPress : undefined}
onTouchStart={canModify ? handleTouchStart : undefined}
onTouchEnd={canModify ? cancelLongPress : undefined}
onTouchMove={canModify ? cancelLongPress : undefined}
onTouchCancel={canModify ? cancelLongPress : undefined}
>
{TextHelper.renderLinks(
TextHelper.renderMentions(
@@ -175,7 +219,7 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv
<span className="thread-event__edited-badge">({t("edited")})</span>
)}
</div>
{isSender && (
{canModify && (
<div className="thread-event__actions">
<Button
size="nano"

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "react-toastify";
import { TextLoader } from "@/features/ui/components/text-loader";
import { useSearchParams } from "next/navigation";
import { getMailboxThreadsListQueryKey } from "@/features/providers/mailbox";
const SUMMARIZE_TOAST_ID = "summarize-toast";
@@ -33,11 +34,7 @@ export const ThreadSummary = ({
// Build the cache key for the thread
const threadQueryKey = useMemo(() => {
if (!selectedMailboxId || !searchParams) return ["threads"];
const queryKey = ["threads", selectedMailboxId];
if (searchParams.get("search")) {
return [...queryKey, "search"];
}
return [...queryKey, searchParams.toString()];
return getMailboxThreadsListQueryKey(selectedMailboxId, searchParams);
}, [selectedMailboxId, searchParams]);
const queryClient = useQueryClient();

View File

@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
import { ThreadActionBar } from "./components/thread-action-bar"
import { ThreadMessage } from "./components/thread-message"
import { ThreadEvent } from "./components/thread-event"
import { ThreadEvent, isCondensed } from "./components/thread-event"
import { ThreadEventInput } from "./components/thread-event-input"
import { useMailboxContext, TimelineItem, isThreadEvent } from "@/features/providers/mailbox"
import useRead from "@/features/message/use-read"
import useMentionRead from "@/features/message/use-mention-read"
import { useDebounceCallback } from "@/hooks/use-debounce-callback"
import { useVisibilityObserver } from "@/hooks/use-visibility-observer"
import { Message, Thread, ThreadAccessRoleChoices, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models"
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"
import { Banner } from "@/features/ui/components/banner"
@@ -19,6 +21,13 @@ import ThreadViewProvider, { useThreadViewContext } from "./provider";
import useSpam from "@/features/message/use-spam";
import ViewHelper from "@/features/utils/view-helper";
/**
* Fallback height (px) used when measuring the sticky header before the
* DOM ref is populated. Matches the rendered header height closely enough
* for the IntersectionObserver to ignore content hidden behind it.
*/
const STICKY_HEADER_FALLBACK_HEIGHT = 125;
type MessageWithDraftChild = Message & {
draft_message?: Message;
}
@@ -49,10 +58,72 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
const { isReady, reset, hasBeenInitialized, setHasBeenInitialized } = useThreadViewContext();
// Refs for all unread messages
const unreadRefs = useRef<Record<string, HTMLElement | null>>({});
// Refs for thread events with unread mentions
const mentionRefs = useRef<Record<string, HTMLElement | null>>({});
const { markMentionsRead } = useMentionRead(thread.id);
// Find all unread message IDs
const messages = useMemo(() => threadItems.filter(item => item.type === 'message').map(item => item.data as MessageWithDraftChild), [threadItems]);
const unreadMessageIds = useMemo(() => messages.filter((m) => m.is_unread).map((m) => m.id), [messages]);
const draftMessageIds = useMemo(() => messages.filter((m) => m.draft_message).map((m) => m.id), [messages]);
const unreadMentionEventIds = useMemo(() =>
threadItems
.filter((item): item is Extract<typeof item, { type: 'event' }> =>
item.type === 'event' && (item.data as ThreadEventModel).has_unread_mention === true
)
.map(item => item.data.id),
[threadItems]
);
/**
* Walks the timeline once to build a map <rootId, true> for
* condensed-IM groups that contain at least one unread mention.
*
* A "root" is an IM event whose header is rendered (first event
* of a condensed run). Surfacing the unread-mention badge on the root
* means that a mention on a condensed sibling — whose own header is
* hidden — still draws the user's eye via the root's header.
*/
const unreadMentionGroupMap = useMemo(() => {
const map = new Map<string, boolean>();
let currentRootId: string | null = null;
let prevEvent: ThreadEventModel | null = null;
for (const item of threadItems) {
if (!isThreadEvent(item)) {
currentRootId = null;
prevEvent = null;
continue;
}
const current = item.data as ThreadEventModel;
if (!isCondensed(current, prevEvent)) {
currentRootId = current.id;
}
if (current.has_unread_mention && currentRootId) {
map.set(currentRootId, true);
}
prevEvent = current;
}
return map;
}, [threadItems]);
// Mention IDs accumulated across debounce windows. The intersection
// observer can fire several times within the 150ms window; using a ref
// (instead of a value passed to the debounced callback) preserves earlier
// batches that would otherwise be overwritten by the trailing-edge debounce.
const pendingMentionIdsRef = useRef<Set<string>>(new Set());
// Mentions already PATCHed during this thread session. `useMentionRead`
// intentionally does not update the thread events cache (to keep the
// "Mentioned" badge visible for the whole session), so an event stays in
// `unreadMentionEventIds` until the next natural refetch. Without this
// guard, scrolling back onto a flagged event would re-enqueue it on every
// visibility pass and trigger redundant PATCH + stats invalidations.
// Cleared on thread switch via the cleanup effect below.
const sentMentionIdsRef = useRef<Set<string>>(new Set());
const flushPendingMentions = useCallback(() => {
if (pendingMentionIdsRef.current.size === 0) return;
const ids = [...pendingMentionIdsRef.current];
pendingMentionIdsRef.current.clear();
markMentionsRead(ids);
}, [markMentionsRead]);
const debouncedFlushMentions = useDebounceCallback(flushPendingMentions, 150);
const isThreadTrashed = stats.trashed === stats.total;
const isThreadArchived = stats.archived === stats.total;
const isThreadSender = messages?.some((m) => m.is_sender);
@@ -76,52 +147,69 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
}, []);
/**
* Setup an intersection observer to mark messages as read when they are
* scrolled into view.
* Mark messages as read once they scroll into view. Tracks the latest
* timestamp seen so a single debounced call covers all messages above it.
*/
useEffect(() => {
if (!unreadMessageIds.length || !isReady) return;
const stickyContainerHeight = stickyContainerRef.current?.getBoundingClientRect().height || 125;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const topOffset = stickyContainerRef.current?.getBoundingClientRect().height || STICKY_HEADER_FALLBACK_HEIGHT;
useVisibilityObserver({
enabled: isReady,
ids: unreadMessageIds,
refs: unreadRefs,
rootRef,
topOffset,
onVisible: (entry) => {
const createdAt = entry.target.getAttribute('data-created-at');
if (!createdAt) return;
// Track the most recent message scrolled into view
if (!latestSeenDate.current || new Date(createdAt) > new Date(latestSeenDate.current)) {
latestSeenDate.current = createdAt;
}
debouncedMarkAsRead(thread.id, latestSeenDate.current);
},
});
}, { root: rootRef.current, rootMargin: `-${stickyContainerHeight}px 0px 0px 0px` });
unreadMessageIds.forEach(messageId => {
const el = unreadRefs.current[messageId];
if (el) {
observer.observe(el);
}
/**
* Mark mentions as read when their ThreadEvent scrolls into view.
* IDs are accumulated through `pendingMentionIdsRef` so several batches
* within the same debounce window are flushed together.
*/
useVisibilityObserver({
enabled: isReady,
ids: unreadMentionEventIds,
refs: mentionRefs,
rootRef,
topOffset,
onVisible: (entry) => {
const eventId = entry.target.getAttribute('data-event-id');
if (!eventId) return;
if (sentMentionIdsRef.current.has(eventId)) return;
sentMentionIdsRef.current.add(eventId);
pendingMentionIdsRef.current.add(eventId);
debouncedFlushMentions();
},
});
return () => {
observer.disconnect();
};
}, [isReady, unreadMessageIds.join(","), thread.id]);
useEffect(() => {
if (isReady && !hasBeenInitialized) {
let messageToScroll = latestMessage?.id;
let selector = `#thread-message-${messageToScroll}`;
let selector = `#thread-message-${latestMessage?.id}`;
if (draftMessageIds.length > 0) {
messageToScroll = draftMessageIds[0];
selector = `#thread-message-${messageToScroll} > .thread-message__reply-form`;
} else if (unreadMessageIds.length > 0) {
messageToScroll = unreadMessageIds[0];
selector = `#thread-message-${messageToScroll}`;
// Drafts take precedence: jump straight to the reply form.
selector = `#thread-message-${draftMessageIds[0]} > .thread-message__reply-form`;
} else {
// Otherwise, scroll to the earliest unread item in chronological
// order — either an unread message or a ThreadEvent (IM) carrying
// an unread mention of the current user.
const firstUnreadItem = threadItems.find((item) => {
if (item.type === 'message') {
return (item.data as MessageWithDraftChild).is_unread;
}
return (item.data as ThreadEventModel).has_unread_mention === true;
});
if (firstUnreadItem) {
selector = firstUnreadItem.type === 'message'
? `#thread-message-${firstUnreadItem.data.id}`
: `#thread-event-${firstUnreadItem.data.id}`;
}
}
const el = document.querySelector<HTMLElement>(selector);
@@ -141,6 +229,8 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
useEffect(() => () => {
reset();
setEditingEvent(null);
pendingMentionIdsRef.current.clear();
sentMentionIdsRef.current.clear();
}, [thread.id]);
return (
@@ -202,7 +292,26 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
if (isThreadEvent(item)) {
const prevItem = index > 0 ? threadItems[index - 1] : null;
const prevEvent = isThreadEvent(prevItem) ? prevItem.data : null;
return <ThreadEvent key={`event-${item.data.id}`} event={item.data} previousEvent={prevEvent} onEdit={setEditingEvent} onDelete={handleEventDelete} />;
const eventData = item.data as ThreadEventModel;
return (
<ThreadEvent
key={`event-${item.data.id}`}
event={item.data}
onEdit={setEditingEvent}
onDelete={handleEventDelete}
isCondensed={isCondensed(eventData, prevEvent)}
// `hasUnreadMention` is group-aware so the badge surfaces on the
// condensed root, but `mentionRef` stays gated by the raw event
// flag on purpose: the read is only acknowledged once the bubble
// that actually carries the mention scrolls into view, not when
// the root header alone is visible.
hasUnreadMention={unreadMentionGroupMap.get(item.data.id) ?? false}
mentionRef={eventData.has_unread_mention
? (el: HTMLDivElement | null) => { mentionRefs.current[item.data.id] = el; }
: undefined
}
/>
);
}
const message = item.data as MessageWithDraftChild;
const isLatest = latestMessage?.id === message.id;

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getMailboxThreadsListQueryKeyPrefix, useMailboxContext } from "@/features/providers/mailbox";
import { threadsEventsReadMentionPartialUpdate } from "@/features/api/gen/thread-events/thread-events";
type UseMentionReadReturn = {
markMentionsRead: (threadEventIds: string[]) => void;
};
/**
* Hook to acknowledge mention UserEvents as read for a given thread.
*
* The thread id is bound at init time because the backend endpoint is
* nested under `/threads/{thread_id}/events/`, and because a given caller
* (typically the thread view) is always scoped to a single thread. The
* endpoint is unitary (PATCH per ThreadEvent); this hook fans out to N
* parallel calls when the intersection observer batches several events in
* the same debounce window.
*
* Cache strategy (invalidation-only, no optimistic updates):
* 1. Stats cache → invalidated on settle so the sidebar badge reflects
* the server-authoritative count.
* 2. Thread list cache → invalidated on settle so threads leave the
* has_unread_mention=1 filter once no unread mention remains.
*
* We deliberately avoid optimistic updates on stats. The mailbox stats
* cache is multi-keyed (global `['threads', 'stats', mailboxId]` coexists
* with per-label `['threads', 'stats', mailboxId, 'label_slug=…']` entries
* under the same prefix), so any `setQueriesData` here would fan out to
* label counters that must not be touched. Keeping this flow
* invalidation-only is simpler and stays consistent with how the rest of
* the app treats the stats cache (see `invalidateThreadsStats`).
*
* The thread events cache is also deliberately NOT touched. Keeping
* `has_unread_mention=true` on the currently displayed thread events means
* the "Mentioned" badge stays visible for the whole thread session, giving
* the user time to actually notice why the thread was flagged. The cache
* gets refreshed naturally on the next refetch (thread switch + return,
* window refocus, manual refresh), at which point the badge disappears.
*/
const useMentionRead = (threadId: string): UseMentionReadReturn => {
const { selectedMailbox, invalidateThreadsStats } = useMailboxContext();
const queryClient = useQueryClient();
const markMentionsRead = useCallback((threadEventIds: string[]) => {
if (!threadEventIds.length) return;
Promise.all(
threadEventIds.map((id) =>
threadsEventsReadMentionPartialUpdate(threadId, id),
),
)
.catch(() => {
// Swallow: the invalidation below will reconcile with the
// server state, so a transient PATCH failure is self-healing
// on the next refetch.
})
.finally(() => {
invalidateThreadsStats();
queryClient.invalidateQueries({ queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) });
});
}, [threadId, selectedMailbox?.id, queryClient, invalidateThreadsStats]);
return { markMentionsRead };
};
export default useMentionRead;

View File

@@ -72,6 +72,81 @@ type MailboxContextType = {
export const isThreadEvent = (item: TimelineItem | null): item is Extract<TimelineItem, { type: 'event' }> => item?.type === 'event';
/**
* Canonical query key for the threads stats query.
*
* Single source of truth shared by:
* - query definition sites (`useThreadsStatsRetrieve` call sites that
* pass `queryParams` to scope the cache per filter/label)
* - invalidation / optimistic-update sites that target the whole
* per-mailbox stats subtree (omit `queryParams` for prefix matching)
*
* Keep in sync with the `invalidateThreadsStats` predicate, which relies
* on `queryParams` being the last entry to filter out `label_slug=*` keys.
*/
export const getThreadsStatsQueryKey = (
mailboxId: string | undefined,
queryParams?: string,
) => {
const base = ['threads', 'stats', mailboxId];
return queryParams !== undefined ? [...base, queryParams] : base;
};
/** Minimal subset of URLSearchParams we read from. Accepts both
* `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
type ReadonlySearchParamsLike = {
get: (key: string) => string | null;
toString: () => string;
};
/**
* Query key prefix for the threads LIST query of a mailbox.
*
* Used for invalidation and for prefix-matching optimistic updates
* (`setQueriesData`) that should apply to every filter variant of
* the same mailbox (list, search, all filter combinations…) in one shot.
*/
export const getMailboxThreadsListQueryKeyPrefix = (mailboxId: string | undefined) =>
['threads', mailboxId];
/**
* Query key prefix for the SEARCH subtree of a mailbox's threads list.
*
* Matches every search variant of the mailbox (different filter combinations
* applied on top of a search term). Used by the search cleanup effect to
* purge or reset all search cache entries in one shot.
*/
export const getMailboxThreadsListSearchQueryKeyPrefix = (mailboxId: string | undefined) =>
[...getMailboxThreadsListQueryKeyPrefix(mailboxId), 'search'];
/**
* Full query key for a threads LIST query, disambiguated by filter.
*
* Key shape: `['threads', mailboxId, bucket, otherParams]`
* - `bucket`: `'search'` when a search term is active, `'list'` otherwise.
* This lets us target the whole search subtree by prefix without
* enumerating filter variants.
* - `otherParams`: stringified searchParams **without** the `search` value.
* Keeping non-search params in the key ensures that applying a filter
* (e.g. `has_unread=1`) while in search mode spawns a distinct cache
* entry instead of reusing stale pages from another filter variant.
*
* The search term itself is intentionally dropped from the key so that
* typing in the search box mutates a single, stable entry per filter
* combination — the cleanup effect then forces a refetch when the term
* actually changes, avoiding a trail of orphaned cache entries.
*/
export const getMailboxThreadsListQueryKey = (
mailboxId: string | undefined,
searchParams: ReadonlySearchParamsLike,
) => {
const prefix = getMailboxThreadsListQueryKeyPrefix(mailboxId);
const normalized = new URLSearchParams(searchParams.toString());
const hasSearch = Boolean(normalized.get('search'));
if (hasSearch) normalized.delete('search');
return [...prefix, hasSearch ? 'search' : 'list', normalized.toString()];
};
const MailboxContext = createContext<MailboxContextType>({
mailboxes: null,
threads: null,
@@ -148,8 +223,10 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
if (!mailboxQuery.data?.data.length) return null;
const mailboxId = router.query.mailboxId;
return mailboxQuery.data?.data.find((mailbox) => mailbox.id === mailboxId)
?? mailboxQuery.data.data.findLast(m => m.role === MailboxRoleChoices.admin)
const matched = mailboxQuery.data.data.find((mailbox) => mailbox.id === mailboxId);
if (matched) return matched;
return mailboxQuery.data.data.findLast(m => m.role === MailboxRoleChoices.admin)
?? mailboxQuery.data.data.findLast(m => m.role === MailboxRoleChoices.editor)
?? mailboxQuery.data.data.findLast(m => m.role === MailboxRoleChoices.sender)
?? mailboxQuery.data.data.findLast(m => m.role === MailboxRoleChoices.viewer)
@@ -158,13 +235,11 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
const previousUnreadThreadsCount = usePrevious(selectedMailbox?.count_unread_threads);
const previousDeliveringCount = usePrevious(selectedMailbox?.count_delivering);
const threadQueryKey = useMemo(() => {
const queryKey = ['threads', selectedMailbox?.id];
if (searchParams.get('search')) {
return [...queryKey, 'search'];
}
return [...queryKey, searchParams.toString()];
}, [selectedMailbox?.id, searchParams]);
const previousUnreadMentionsCount = usePrevious(selectedMailbox?.count_unread_mentions);
const threadQueryKey = useMemo(
() => getMailboxThreadsListQueryKey(selectedMailbox?.id, searchParams),
[selectedMailbox?.id, searchParams]
);
const threadsQuery = useThreadsListInfinite(undefined, {
query: {
enabled: !!selectedMailbox,
@@ -294,6 +369,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
return flattenThreads?.results.find((thread) => thread.id === threadId) ?? null;
}, [router.query.threadId, flattenThreads])
const previousSelectedThreadMessagesCount = usePrevious(selectedThread?.messages.length);
const previousSelectedThreadEventsCount = usePrevious(selectedThread?.events_count);
const messagesQuery = useMessagesList({
query: {
@@ -379,7 +455,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
readAt: string | null,
) => {
queryClient.setQueriesData<InfiniteData<threadsListResponse>>(
{ queryKey: ['threads', mailboxId] },
{ queryKey: getMailboxThreadsListQueryKeyPrefix(mailboxId) },
(oldData) => {
if (!oldData) return oldData;
return {
@@ -419,7 +495,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
starredAt: string | null,
) => {
queryClient.setQueriesData<InfiniteData<threadsListResponse>>(
{ queryKey: ['threads', mailboxId] },
{ queryKey: getMailboxThreadsListQueryKeyPrefix(mailboxId) },
(oldData) => {
if (!oldData) return oldData;
return {
@@ -499,7 +575,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
(source?.metadata.threadIds ?? []).forEach(id =>
optimisticThreadIdsRef.current.delete(id)
);
await queryClient.invalidateQueries({ queryKey: ['threads', selectedMailbox?.id] });
await queryClient.invalidateQueries({ queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) });
}
if (selectedThread) {
@@ -515,7 +591,12 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
const invalidateThreadsStats = async () => {
await queryClient.invalidateQueries({
queryKey: ['threads', 'stats', selectedMailbox?.id],
queryKey: getThreadsStatsQueryKey(selectedMailbox?.id),
// Exclude per-label stats queries (`label_slug=…`) from the
// fan-out: a global invalidation would otherwise trigger one
// re-fetch per label in the sidebar, which is wasteful. Label
// counts stay fresh via their own targeted refetch paths
// (label mutations) and by the mailbox polling loop.
predicate: ({ queryKey }) => !(queryKey[queryKey.length - 1] as string).startsWith('label_slug=')
});
}
@@ -613,7 +694,8 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
}, [flattenThreads]);
// Invalidate the threads query when mailbox stats change (unread messages or delivering count)
// Invalidate the threads query when mailbox stats change (unread messages,
// delivering count or unread mentions)
useEffect(() => {
if (!selectedMailbox) return;
@@ -625,11 +707,15 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
previousDeliveringCount !== undefined &&
previousDeliveringCount !== selectedMailbox.count_delivering;
if (hasUnreadCountChanged || hasDeliveringCountChanged) {
const hasUnreadMentionsCountChanged =
previousUnreadMentionsCount !== undefined &&
previousUnreadMentionsCount !== selectedMailbox.count_unread_mentions;
if (hasUnreadCountChanged || hasDeliveringCountChanged || hasUnreadMentionsCountChanged) {
invalidateThreadsStats();
queryClient.invalidateQueries({ queryKey: ['threads', selectedMailbox?.id] });
queryClient.invalidateQueries({ queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) });
}
}, [selectedMailbox?.count_unread_threads, selectedMailbox?.count_delivering]);
}, [selectedMailbox?.count_unread_threads, selectedMailbox?.count_delivering, selectedMailbox?.count_unread_mentions]);
// Invalidate the thread messages query to refresh the thread messages when there is a new message
useEffect(() => {
@@ -639,6 +725,15 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
}
}, [selectedThread?.messages.length]);
// Invalidate the thread events query to refresh the thread events when a new
// event (e.g. a mention) is added to the currently open thread.
useEffect(() => {
if (!selectedThread || previousSelectedThreadEventsCount === undefined) return;
if (previousSelectedThreadEventsCount < (selectedThread?.events_count ?? 0)) {
invalidateThreadEvents();
}
}, [selectedThread?.events_count]);
// Unselect the thread when it no longer has any messages (e.g. after
// sending the only draft in the thread).
useEffect(() => {
@@ -660,16 +755,20 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
const currentSearch = searchParams.get('search');
if (previousSearch && !currentSearch) {
// Exiting search mode: purge cached search results so re-entering
// search doesn't briefly flash stale results from the previous query.
// Exiting search mode: purge every cached search variant so
// re-entering search doesn't briefly flash stale results from
// the previous query (prefix match covers all filter variants).
queryClient.removeQueries({
queryKey: ['threads', selectedMailbox?.id, 'search'],
exact: true,
queryKey: getMailboxThreadsListSearchQueryKeyPrefix(selectedMailbox?.id),
});
} else if (previousSearch && currentSearch && currentSearch !== previousSearch) {
// Search term changed while already in search mode:
// reset the query to force a refetch with the new params.
queryClient.resetQueries({ queryKey: ['threads', selectedMailbox?.id, 'search'] });
// Search term changed while already in search mode: the query key
// intentionally omits the search term so React Query would reuse
// the cache — reset every search variant to force a refetch with
// the new term.
queryClient.resetQueries({
queryKey: getMailboxThreadsListSearchQueryKeyPrefix(selectedMailbox?.id),
});
}
unselectThread();

View File

@@ -1,21 +1,27 @@
import { SearchHelper } from "../search-helper";
import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
import { ALL_MESSAGES_FOLDER, MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
// Type for the union of all folder id values, generated from MAILBOX_FOLDERS
export type ViewName = ReturnType<typeof MAILBOX_FOLDERS>[number]['id'];
// plus the virtual "all_messages" folder which lives outside the sidebar.
export type ViewName =
| ReturnType<typeof MAILBOX_FOLDERS>[number]['id']
| ReturnType<typeof ALL_MESSAGES_FOLDER>['id'];
class ViewHelper {
#isView(viewName: ViewName): boolean {
if (typeof window === 'undefined') return false;
const searchParams = new URLSearchParams(window.location.search);
const folder = MAILBOX_FOLDERS().find((folder) => folder.id === viewName);
const folder = viewName === 'all_messages'
? ALL_MESSAGES_FOLDER()
: MAILBOX_FOLDERS().find((folder) => folder.id === viewName);
if (!folder) throw new Error(`${viewName} folder not found. Invalid folder id "${viewName}".`);
const matchViewFilters = Object.entries(folder.filter || {}).every(([key, value]) => searchParams.get(key) === value);
if (matchViewFilters) return true;
const matchSearchParams = folder.searchable && SearchHelper.parseSearchQuery(searchParams.get('search') || '')?.in === viewName;
const isSearchable = viewName === 'all_messages' || ('searchable' in folder && folder.searchable);
const matchSearchParams = isSearchable && SearchHelper.parseSearchQuery(searchParams.get('search') || '')?.in === viewName;
return matchSearchParams;
}

View File

@@ -0,0 +1,67 @@
import { RefObject, useEffect, useRef } from 'react';
type ElementRefs = Record<string, HTMLElement | null>;
type UseVisibilityObserverOptions = {
/** Disable the observer entirely (e.g. while the parent view is not ready). */
enabled: boolean;
/** IDs of the elements to watch — used to keep the observer in sync with the rendered DOM. */
ids: readonly string[];
/** Mutable record holding `id → DOM element` references populated by the parent. */
refs: RefObject<ElementRefs>;
/** Scroll container used as the IntersectionObserver root. */
rootRef: RefObject<HTMLElement | null>;
/** Optional top offset subtracted from the top of the viewport. */
topOffset?: number;
/** Called for each element that crosses into view. */
onVisible: (entry: IntersectionObserverEntry) => void;
};
/**
* Watches a list of DOM elements and fires `onVisible` whenever one of them
* scrolls into view of `rootRef`. Encapsulates the IntersectionObserver setup,
* sticky-header offset and lifecycle that would otherwise be duplicated by
* each scroll-to-acknowledge feature (mark-as-read, mention acknowledgment…).
*
* `onVisible` does not need to be memoized: it is captured through a ref so
* that updates do not tear down the observer.
*/
export function useVisibilityObserver({
enabled,
ids,
refs,
rootRef,
topOffset = 0,
onVisible,
}: UseVisibilityObserverOptions): void {
// Stable callback wrapper — keeps the observer alive across renders even
// when the parent passes an inline arrow function.
const onVisibleRef = useRef(onVisible);
useEffect(() => {
onVisibleRef.current = onVisible;
}, [onVisible]);
const idsKey = ids.join(',');
useEffect(() => {
if (!enabled || ids.length === 0) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) onVisibleRef.current(entry);
});
}, {
root: rootRef.current,
rootMargin: `-${topOffset}px 0px 0px 0px`,
});
ids.forEach((id) => {
const el = refs.current[id];
if (el) observer.observe(el);
});
return () => observer.disconnect();
// `ids` is intentionally tracked through `idsKey` to avoid re-running
// when the array reference changes but the contents do not.
}, [enabled, idsKey, topOffset, refs, rootRef]);
}