mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
✨(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:
committed by
GitHub
parent
4a59033a98
commit
1044614a76
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
533
src/backend/core/tests/api/test_thread_filter_mention.py
Normal file
533
src/backend/core/tests/api/test_thread_filter_mention.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
179
src/backend/core/tests/models/test_user_event.py
Normal file
179
src/backend/core/tests/models/test_user_event.py
Normal 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
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
3
src/frontend/package-lock.json
generated
3
src/frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
src/frontend/src/features/message/use-mention-read.tsx
Normal file
67
src/frontend/src/features/message/use-mention-read.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
67
src/frontend/src/hooks/use-visibility-observer.ts
Normal file
67
src/frontend/src/hooks/use-visibility-observer.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user