Skip to content

feat/mention-event#621

Merged
jbpenrath merged 1 commit into
mainfrom
feat/mention-event
Apr 9, 2026
Merged

feat/mention-event#621
jbpenrath merged 1 commit into
mainfrom
feat/mention-event

Conversation

@jbpenrath
Copy link
Copy Markdown
Contributor

@jbpenrath jbpenrath commented Apr 7, 2026

Purpose

A user is able to mention other users in a instant message. We now add inbox subfolders, to retrieve with ease messages where the authenticated users has been mentionned.

Capture d’écran 2026-04-08 à 19 38 32 copie

Summary by CodeRabbit

  • New Features

    • Mentioning: threads and events can mention users; “Mentioned” sidebar, per-thread “Unread mention” badges, and unread-mention counts shown.
    • Mark-as-read: new action to acknowledge unread mentions (idempotent) from thread view.
    • Edit window: configurable time window controls whether internal comments can be edited or deleted.
    • UI improvements: collapsible inbox folders, expanded count/label variants, and new translations for mention-related strings.
  • Documentation

    • Documented configurable edit-delay setting.

@jbpenrath jbpenrath self-assigned this Apr 7, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds mention tracking with a new UserEvent model, DB migration, sync logic on ThreadEvent saves, mention-read PATCH endpoint, mention-related query annotations/filters/stats, edit-window gating for ThreadEvent edits/deletes, frontend mention UI/visibility batching, tests, and admin/factory updates.

Changes

Cohort / File(s) Summary
Models & Migration
src/backend/core/models.py, src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
Add UserEvent model (MENTION type, read_at, denormalized thread), change ThreadEvent.type choices callable, add ThreadEvent.is_editable(), and schema migration.
Signals & Sync Logic
src/backend/core/signals.py
Parse/validate mention user IDs, _validate_user_ids_with_access(), sync_mention_user_events() to reconcile MENTION UserEvent rows, and post-save receiver to trigger sync for IM events.
API: Viewsets & Permissions
src/backend/core/api/viewsets/thread.py, src/backend/core/api/viewsets/thread_event.py, src/backend/core/api/permissions.py
Annotate threads/events with mention flags and events_count; add read-mention PATCH action; enforce immutability via is_editable(); adjust permission checks and add HasThreadEditAccess.has_permission.
API: Serializers & OpenAPI
src/backend/core/api/serializers.py, src/backend/core/api/openapi.json
Expose has_unread_mention and events_count, add mailbox count_unread_mentions integer, add has_mention/has_unread_mention query params, and document read-mention endpoint.
Enums, Settings & Factories
src/backend/core/enums.py, src/backend/messages/settings.py, src/backend/core/factories.py
Add UserEventTypeChoices and choice helper, extend THREAD_STATS_FIELDS_MAP, add MAX_THREAD_EVENT_EDIT_DELAY setting, and add UserEventFactory.
Admin
src/backend/core/admin.py
Add UserEventInline (read-only, no add/delete) to ThreadAdmin.inlines.
Backend Tests & Conftest
src/backend/core/tests/..., src/backend/core/tests/conftest.py
Large test additions covering mention sync on edits, read-mention endpoint, filters/stats, mailbox counts, UserEvent model semantics, split handling, and an autouse invariant fixture.
Frontend: Hooks, Providers & Thread view
src/frontend/src/features/message/use-mention-read.tsx, src/frontend/src/hooks/use-visibility-observer.ts, src/frontend/src/features/providers/mailbox.tsx, src/frontend/src/features/layouts/components/thread-view/index.tsx, .../thread-event/index.tsx
Add useMentionRead hook calling read-mention, useVisibilityObserver, visibility-driven batching to mark mentions read, pass hasUnreadMention/mentionRef to events, and update query-key/invalidation helpers.
Frontend: Thread panel / mailbox list / filters
src/frontend/src/features/layouts/components/.../mailbox-list/index.tsx, .../thread-panel/*, .../use-thread-panel-filters.ts, .../thread-panel-filter.tsx
Introduce has_mention filter, nested Inbox children (Mentioned), centralized apply/clear filters, persist expanded folders, and adjust query keys.
Frontend: UI & i18n
src/frontend/public/locales/*, src/frontend/src/features/.../thread-panel/components/thread-item/index.tsx, SCSS files
Add translation keys for mentions, render unread-mention badges in thread items and thread events, styling tweaks and icon sizing changes.
E2E & Test Utilities
src/e2e/src/__tests__/*, src/e2e/src/utils-test.ts, src/backend/e2e/management/commands/e2e_demo.py
Add e2e tests for mention flows and edit-delay, inboxFolderLink helper, and seed aged IM events for demo.
Docs
docs/env.md
Document MAX_THREAD_EVENT_EDIT_DELAY env var.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Frontend
  participant API as Backend API
  participant DB as Database
  participant Signal as PostSaveHandler

  Client->>API: GET /api/v1.0/threads/?has_unread_mention=1
  API->>DB: Query threads annotated with Exists(UserEvent type=MENTION, read_at IS NULL)
  DB-->>API: Annotated rows
  API-->>Client: Threads JSON (has_unread_mention, events_count, ...)

  Client->>API: PATCH /threads/{thread}/events/{event}/read-mention/
  API->>DB: Update UserEvent set read_at=now() for user,event,type=MENTION
  DB-->>API: 204

  Client->>API: PATCH /threads/{thread}/events/{event}/ (edit IM)
  API->>DB: Update ThreadEvent
  DB-->>Signal: post_save(ThreadEvent)
  Signal->>DB: sync_mention_user_events() (delete/create UserEvent rows)
  DB-->>Signal: sync complete
  API-->>Client: 200 (updated ThreadEvent)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Suggested reviewers

  • sylvinus

"🐰
Mentions hop through threads so bright,
Badges glow to catch your sight,
Edits close with ticking time,
Threads and counts now sing in rhyme,
Syncs and seeds keep everything right."

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'feat/mention-event' uses a branch naming convention rather than a descriptive summary sentence. It refers to the implementation detail (mention-event feature) but lacks context about the main user-facing goal: adding inbox subfolders for mentioned messages. Revise the title to be a clear, single-sentence summary such as 'Add mention folder to inbox for surfacing mentioned messages' or 'Support filtering messages by mentions with new inbox subfolder'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 98.43% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mention-event

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jbpenrath jbpenrath changed the title !wip user-event mention feat/mention-event Apr 7, 2026
@jbpenrath jbpenrath force-pushed the feat/mention-event branch 2 times, most recently from 303d9bb to 1f1f94f Compare April 7, 2026 13:17
@jbpenrath
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx (1)

25-30: ⚠️ Potential issue | 🔴 Critical

Missing mentionRef prop in type definition.

The mentionRef prop is used in the component (line 60) and referenced at line 181, but is not declared in ThreadEventProps. This will cause a TypeScript compilation error since the destructuring includes a property not defined in the type.

Proposed fix
 type ThreadEventProps = {
     event: ThreadEventType;
     previousEvent?: ThreadEventType | null;
     onEdit?: (event: ThreadEventType) => void;
     onDelete?: (eventId: string) => void;
+    mentionRef?: React.RefCallback<HTMLSpanElement> | React.RefObject<HTMLSpanElement> | null;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`
around lines 25 - 30, Add the missing optional mentionRef prop to the
ThreadEventProps type so TypeScript knows the component accepts it; declare it
as something like mentionRef?: React.RefObject<HTMLElement> (or
React.Ref<HTMLDivElement>) in the ThreadEventProps definition used by the
ThreadEvent component, and ensure React is imported if not already so the type
resolves.
src/backend/core/api/viewsets/thread.py (1)

320-390: ⚠️ Potential issue | 🟠 Major

has_*mention_unread stats will raise FieldError.

The generic _unread branch builds Q(has_unread_mention=True) / Q(has_mention=True), but those names only exist as annotations with a leading underscore. Requests for has_unread_mention_unread or has_mention_unread therefore hit nonexistent model fields.

Suggested fix
             elif field == "has_starred_unread":
                 aggregations[agg_key] = Count(
                     "pk", filter=starred_condition & unread_condition
                 )
+            elif field == "has_unread_mention_unread":
+                aggregations[agg_key] = Count(
+                    "pk", filter=unread_mention_condition & unread_condition
+                )
+            elif field == "has_mention_unread":
+                aggregations[agg_key] = Count(
+                    "pk", filter=mention_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)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/thread.py` around lines 320 - 390, The issue is
that annotated flags are named with a leading underscore (e.g. _has_starred,
_has_unread_mention) but the code builds Q(...) using un-prefixed names when
handling field.endswith("_unread") and the final else, causing FieldError for
requests like has_mention_unread; fix by mapping base_field/field to the
annotated name (prefix with "_" before building Q) in the "_unread" branch
(where base_field = field[:-7]) and in the final else branch where Q(**{field:
True}) is created—use Q(**{f"_{base_field}": True}) and Q(**{f"_{field}": True})
respectively, referencing the requested_fields loop, base_field variable,
aggregations dict, and the unread_condition/unread_mention_condition variables
to locate the change.
src/backend/core/api/openapi.json (1)

5909-5925: ⚠️ Potential issue | 🟠 Major

stats_fields is still typed as a single enum value, not the comma-separated list the docs describe.

Line 5912 defines one enum-backed string, but Line 5922 documents combinations like all,all_unread and generic _unread suffixes. Generated clients will reject those documented values unless this is modeled as an array or the description/examples are narrowed to a single accepted value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/openapi.json` around lines 5909 - 5925, The stats_fields
parameter is defined as a single enum string but the docs describe
comma-separated multiple values; update the OpenAPI schema for stats_fields to
be an array of strings with the same enum items (and keep style: "form" and
explode: false so clients accept comma-separated lists) so clients can pass
values like "all,all_unread" or combinations with "_unread"; modify the schema
object under the stats_fields parameter (replace type: "string" + enum with
type: "array" + items: { type: "string", enum: [...] }) while preserving the
description, required, style, and explode settings.
🧹 Nitpick comments (8)
src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx (1)

58-62: Remove unused selectedMailbox from useMemo dependency array.

The selectedMailbox variable is included in the dependency array but is not used in the calculation. This could cause unnecessary re-computations.

♻️ Proposed fix
     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 findFolder((folder) => new URLSearchParams(folder.filter).toString() === folderSearchParams.toString())?.name;
-    }, [searchParams, folderSearchParams, labelsQuery.data?.data, selectedMailbox, t])
+    }, [searchParams, folderSearchParams, labelsQuery.data?.data, t])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`
around lines 58 - 62, The useMemo that computes the "title" (const title =
useMemo(...)) includes selectedMailbox in its dependency array but
selectedMailbox is not referenced inside the memo callback; remove
selectedMailbox from the dependency list so the memo only depends on
searchParams, folderSearchParams, labelsQuery.data?.data and t to avoid
unnecessary recomputations.
src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx (2)

167-180: findFolder only supports one level of nesting.

The current implementation searches top-level folders and their direct children, but won't find folders nested deeper. This is fine for the current structure, but if deeper nesting is added in the future, a recursive implementation would be needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`
around lines 167 - 180, The findFolder function currently only checks top-level
folders and their immediate children, so it misses folders nested deeper; update
findFolder to perform a recursive search over MAILBOX_FOLDERS() (and each
folder.children array) so it descends any depth until predicate(folder) is true,
referencing the existing function name findFolder, the MAILBOX_FOLDERS() getter,
the Folder.children property, and the predicate parameter to guide where to
check and when to return the matching Folder or undefined.

151-151: Removing satisfies reduces compile-time type safety.

Changing from as const satisfies readonly Folder[] to just as const means TypeScript won't validate that each folder object conforms to the Folder type at the definition site. This relies on the runtime casts at lines 172 and 196 to catch type mismatches.

Consider restoring the satisfies clause or using a typed function to maintain type safety:

♻️ Suggested fix
-] as const;
+] as const satisfies readonly Folder[];

This ensures the folder objects match the Folder type while preserving literal type inference for properties like id.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`
at line 151, The array of folder objects currently ends with "as const" which
removes compile-time validation against the Folder type; restore the original
type-safety by appending "satisfies readonly Folder[]" to that array definition
(or wrap the array in a typed helper function that returns readonly Folder[]) so
each entry is validated as a Folder while keeping literal inference for
properties like id; also keep the runtime casts currently used elsewhere (the
places casting to Folder) but they should become unnecessary once the array
satisfies readonly Folder[].
src/backend/core/tests/api/test_user_event.py (1)

34-144: Tests cover core scenarios well; consider adding edge cases.

The test coverage for the mark-read endpoint is solid. Consider adding tests for:

  • Empty thread_event_ids array (should return updated=0 or validation error?)
  • is_active=False UserEvents (should they be excluded from bulk update?)

These would improve confidence in the endpoint's robustness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_user_event.py` around lines 34 - 144, Add
tests in TestUserEventMarkRead to cover the missing edge cases: create a test
method (e.g., test_mark_read_empty_ids) that sends an authenticated PATCH to
reverse("user-events-mark-read") with an empty "thread_event_ids": [] and assert
response.status_code is 200 and response.data["updated"] == 0 (or adjust if
validation expected); and create a test (e.g.,
test_mark_read_excludes_inactive_userevents) that creates a UserEvent with
is_active=False for the authenticated user and a matching ThreadEvent, PATCH
with that thread_event id and assert updated==0 and the inactive
UserEvent.read_at remains None; use existing helpers like
setup_user_with_thread_access(), factories.UserEventFactory, and
reverse("user-events-mark-read") to implement both tests.
src/backend/core/models.py (1)

1437-1492: Consider adding a unique constraint to prevent duplicate UserEvent entries.

There's no constraint preventing multiple UserEvent rows for the same (user, thread_event, type) combination. If the signal handler that creates these events runs multiple times (e.g., due to retries or race conditions), duplicates could be created.

🛡️ Suggested constraint addition
     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="unique_user_thread_event_type",
+            ),
+        ]
         indexes = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/models.py` around lines 1437 - 1492, Add a uniqueness
constraint to the UserEvent model to prevent duplicate (user, thread_event,
type) entries: update the UserEvent.Meta to include a
models.UniqueConstraint(fields=["user", "thread_event", "type"],
name="usrevt_user_threvt_type_uniq") and run a migration; before applying the
constraint create a data migration or cleanup step to deduplicate existing rows
for (user, thread_event, type) to avoid migration failures. Ensure you reference
the UserEvent class and its Meta when making the change.
src/backend/core/tests/api/test_thread_event.py (1)

390-400: Consider adding assertion for UserEvent creation on self-assign.

The test verifies 201 status but doesn't confirm that a UserEvent was actually created for the self-assigning user. This would strengthen the test.

💡 Suggested enhancement
         response = api_client.post(get_thread_event_url(thread.id), data, format="json")
         assert response.status_code == status.HTTP_201_CREATED
+
+        # Verify UserEvent ASSIGN was created for self
+        assert models.UserEvent.objects.filter(
+            user=user,
+            thread=thread,
+            type=enums.UserEventTypeChoices.ASSIGN,
+            is_active=True,
+        ).exists()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_thread_event.py` around lines 390 - 400, Add
an assertion that a UserEvent was created for the self-assigned user: after
calling api_client.post in test_create_assign_self_assign, query the UserEvent
model (UserEvent.objects.filter or equivalent) for an event tied to the thread
and the acting user and assert exactly one (or at least one) exists and/or its
type/data matches "assign" and contains the assignee id; reference the test
function test_create_assign_self_assign, the get_thread_event_url call, and the
UserEvent model to locate where to add this check.
src/backend/core/tests/api/test_thread_filter_mention.py (1)

14-29: Consider extracting shared helper to conftest.py.

The setup_user_with_thread_access helper function is duplicated in both test_thread_event.py (line 24) and this file. Extracting it to a shared location (e.g., conftest.py or a test utilities module) would reduce duplication and ensure consistency.

♻️ Suggested refactor

Create or update src/backend/core/tests/api/conftest.py:

import pytest
from core import enums, factories


`@pytest.fixture`
def user_with_thread_access():
    """Fixture factory for creating a user with mailbox and thread access."""
    def _create(role=enums.ThreadAccessRoleChoices.EDITOR):
        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
    return _create

Then in tests:

def test_example(self, api_client, user_with_thread_access):
    user, mailbox, thread = user_with_thread_access()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_thread_filter_mention.py` around lines 14 -
29, The helper function setup_user_with_thread_access is duplicated across
tests; extract it into a shared pytest fixture (e.g., user_with_thread_access)
in the tests package conftest.py so other test modules can reuse it: move the
body of setup_user_with_thread_access into a fixture that returns a callable
(def _create(role=...): ... return user, mailbox, thread) and update tests that
currently call setup_user_with_thread_access to accept the
user_with_thread_access fixture and invoke it; finally remove the duplicate
local setup_user_with_thread_access definitions from the test files (references:
setup_user_with_thread_access, ThreadAccessFactory, MailboxAccessFactory).
src/backend/core/migrations/0024_userevent.py (1)

30-31: Consider logging the backfill progress or removing unused total_created.

The total_created counter is incremented but never logged or returned. For long-running migrations with large datasets, progress logging can be helpful for monitoring and debugging.

💡 Suggested enhancement
+import logging
+
+logger = logging.getLogger(__name__)
+
 def backfill_mention_user_events(apps, schema_editor):
     ...
     if batch:
         UserEvent.objects.bulk_create(batch)
         total_created += len(batch)
+
+    if total_created:
+        logger.info("Backfilled %d UserEvent MENTION records", total_created)

Also applies to: 83-83, 88-88

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/migrations/0024_userevent.py` around lines 30 - 31, The
migration defines and increments a total_created counter (variable
total_created) but never logs or returns it; either remove the unused
total_created and any related increments to avoid dead code, or add progress
logging that emits total_created periodically and at completion (for example
inside the processing loop that uses batch) using the existing logger/print so
long-running backfills report progress; update references to total_created in
the migration function (where batch is processed) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/admin.py`:
- Around line 435-441: UserEventInline allows editing of user and thread_event
which can desync user_event.thread vs user_event.thread_event.thread; fix by
making the inline effectively read-only or constraining thread_event choices:
update UserEventInline to include "user" and "thread_event" in readonly_fields
(or override get_readonly_fields to add them conditionally), and/or override
formfield_for_foreignkey in UserEventInline to limit the thread_event queryset
to only events whose thread matches the parent thread (filter by
thread_event__thread == parent instance) so admins cannot pick a thread_event
from a different thread.

In `@src/backend/core/api/openapi.json`:
- Around line 5975-6015: The requestBody for the mark-read operation currently
allows omitted bodies and the 200 response schema allows a missing updated
count; make the request body required by adding "required": true on the
requestBody object and make the response object require the "updated" property
by adding "required": ["updated"] to the 200 response schema; target the
requestBody that contains the "thread_event_ids" property and the response
schema that defines the "updated" integer to ensure generated SDK types mark the
body and updated count as required.

In `@src/backend/core/api/viewsets/thread_event.py`:
- Around line 34-43: get_permissions is returning
permissions.HasThreadEditAccess() for write actions but that permission's
has_object_permission expects a Thread (not a ThreadEvent), so DRF will call it
with a ThreadEvent and either deny valid editors or raise; fix by either
updating HasThreadEditAccess.has_object_permission to accept a ThreadEvent
(detect when obj is a ThreadEvent and use obj.thread) or create an adapter
permission (e.g., HasThreadEditAccessForThreadEvent) that delegates to
HasThreadEditAccess using obj.thread, then change get_permissions to return that
adapter for update/partial_update/destroy instead of HasThreadEditAccess
directly.

In `@src/backend/core/api/viewsets/user_event.py`:
- Around line 57-65: thread_event_ids is taken straight from request.data and
passed to thread_event_id__in, which will error or behave incorrectly if
thread_event_ids is None or not a list (e.g., a string); validate and coerce it
first. In the view method in src/backend/core/api/viewsets/user_event.py where
thread_event_ids is read and used in
models.UserEvent.objects.filter(...).update(...), require thread_event_ids to be
a list of UUIDs (use a DRF serializer/ListField child=UUIDField or explicitly
check isinstance(thread_event_ids, list) and validate each item as a UUID),
return a 400 Response on invalid input, and only call thread_event_id__in with
the validated list.

In `@src/backend/core/signals.py`:
- Around line 384-416: The handler handle_thread_event_post_save currently
returns early for non-created ThreadEvent instances which prevents resyncing
mentions on edits; update it to always process IM-type events (not just when
created) by removing the early return and invoking the mention synchronization
logic for updates as well—either call/create a function like
create_mention_user_events with a flag for created vs updated or add a new
sync_mention_user_events(thread_event, thread, mentions_data) function that
diffs current mentions and creates/deletes UserEvent rows accordingly; ensure
this logic is triggered inside the existing try/except for instance.type ==
enums.ThreadEventTypeChoices.IM so edits correctly add/remove UserEvent MENTION
records and keep mention/unread state in sync.

In `@src/backend/core/tests/models/test_user_event.py`:
- Around line 26-29: The test test_invalid_type_raises_validation_error
currently expects a ValidationError from saving via
factories.UserEventFactory(type="invalid"), but Django choice validation runs on
full_clean(), not save(); update the test to create an unsaved instance with
factories.UserEventFactory.build(type="invalid") (or create then reload) and
then call instance.full_clean() inside the pytest.raises(ValidationError) block,
or alternatively modify factories.UserEventFactory._create (or the model
UserEvent.save()) to call full_clean() before saving so that creating via
UserEventFactory triggers validation.

In `@src/frontend/src/features/layouts/components/thread-view/index.tsx`:
- Around line 68-71: The current debouncedMarkMentionRead call replaces the ids
array each invocation so overlapping observer callbacks lose earlier IDs; change
to accumulate IDs into a shared Set held in a React ref/closure (e.g.,
pendingMentionIdsRef = useRef<Set<string>>(new Set())), push incoming ids into
that Set inside the observer callback instead of passing them directly to
useDebounceCallback, and have the debounced handler (the function passed into
useDebounceCallback) read Array.from(pendingMentionIdsRef.current), call
markMentionsRead(with that array), then clear the Set; apply the same pattern to
the other similar debounced usage mentioned (lines 139-151) so all concurrent
callbacks are merged before the debounce fires.

In `@src/frontend/src/features/message/use-mention-read.tsx`:
- Around line 25-27: The markMentionsRead function currently performs cache
operations using selectedMailbox?.id which can be undefined; add a guard that
returns early if selectedMailbox or selectedMailbox.id is falsy before any
query/cache updates (e.g., before calls that use the ['threads','stats',
selectedMailbox?.id] key), so all cache reads/writes (in markMentionsRead and
any helper it calls) only run when a valid selectedMailbox.id exists.

---

Outside diff comments:
In `@src/backend/core/api/openapi.json`:
- Around line 5909-5925: The stats_fields parameter is defined as a single enum
string but the docs describe comma-separated multiple values; update the OpenAPI
schema for stats_fields to be an array of strings with the same enum items (and
keep style: "form" and explode: false so clients accept comma-separated lists)
so clients can pass values like "all,all_unread" or combinations with "_unread";
modify the schema object under the stats_fields parameter (replace type:
"string" + enum with type: "array" + items: { type: "string", enum: [...] })
while preserving the description, required, style, and explode settings.

In `@src/backend/core/api/viewsets/thread.py`:
- Around line 320-390: The issue is that annotated flags are named with a
leading underscore (e.g. _has_starred, _has_unread_mention) but the code builds
Q(...) using un-prefixed names when handling field.endswith("_unread") and the
final else, causing FieldError for requests like has_mention_unread; fix by
mapping base_field/field to the annotated name (prefix with "_" before building
Q) in the "_unread" branch (where base_field = field[:-7]) and in the final else
branch where Q(**{field: True}) is created—use Q(**{f"_{base_field}": True}) and
Q(**{f"_{field}": True}) respectively, referencing the requested_fields loop,
base_field variable, aggregations dict, and the
unread_condition/unread_mention_condition variables to locate the change.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`:
- Around line 25-30: Add the missing optional mentionRef prop to the
ThreadEventProps type so TypeScript knows the component accepts it; declare it
as something like mentionRef?: React.RefObject<HTMLElement> (or
React.Ref<HTMLDivElement>) in the ThreadEventProps definition used by the
ThreadEvent component, and ensure React is imported if not already so the type
resolves.

---

Nitpick comments:
In `@src/backend/core/migrations/0024_userevent.py`:
- Around line 30-31: The migration defines and increments a total_created
counter (variable total_created) but never logs or returns it; either remove the
unused total_created and any related increments to avoid dead code, or add
progress logging that emits total_created periodically and at completion (for
example inside the processing loop that uses batch) using the existing
logger/print so long-running backfills report progress; update references to
total_created in the migration function (where batch is processed) accordingly.

In `@src/backend/core/models.py`:
- Around line 1437-1492: Add a uniqueness constraint to the UserEvent model to
prevent duplicate (user, thread_event, type) entries: update the UserEvent.Meta
to include a models.UniqueConstraint(fields=["user", "thread_event", "type"],
name="usrevt_user_threvt_type_uniq") and run a migration; before applying the
constraint create a data migration or cleanup step to deduplicate existing rows
for (user, thread_event, type) to avoid migration failures. Ensure you reference
the UserEvent class and its Meta when making the change.

In `@src/backend/core/tests/api/test_thread_event.py`:
- Around line 390-400: Add an assertion that a UserEvent was created for the
self-assigned user: after calling api_client.post in
test_create_assign_self_assign, query the UserEvent model
(UserEvent.objects.filter or equivalent) for an event tied to the thread and the
acting user and assert exactly one (or at least one) exists and/or its type/data
matches "assign" and contains the assignee id; reference the test function
test_create_assign_self_assign, the get_thread_event_url call, and the UserEvent
model to locate where to add this check.

In `@src/backend/core/tests/api/test_thread_filter_mention.py`:
- Around line 14-29: The helper function setup_user_with_thread_access is
duplicated across tests; extract it into a shared pytest fixture (e.g.,
user_with_thread_access) in the tests package conftest.py so other test modules
can reuse it: move the body of setup_user_with_thread_access into a fixture that
returns a callable (def _create(role=...): ... return user, mailbox, thread) and
update tests that currently call setup_user_with_thread_access to accept the
user_with_thread_access fixture and invoke it; finally remove the duplicate
local setup_user_with_thread_access definitions from the test files (references:
setup_user_with_thread_access, ThreadAccessFactory, MailboxAccessFactory).

In `@src/backend/core/tests/api/test_user_event.py`:
- Around line 34-144: Add tests in TestUserEventMarkRead to cover the missing
edge cases: create a test method (e.g., test_mark_read_empty_ids) that sends an
authenticated PATCH to reverse("user-events-mark-read") with an empty
"thread_event_ids": [] and assert response.status_code is 200 and
response.data["updated"] == 0 (or adjust if validation expected); and create a
test (e.g., test_mark_read_excludes_inactive_userevents) that creates a
UserEvent with is_active=False for the authenticated user and a matching
ThreadEvent, PATCH with that thread_event id and assert updated==0 and the
inactive UserEvent.read_at remains None; use existing helpers like
setup_user_with_thread_access(), factories.UserEventFactory, and
reverse("user-events-mark-read") to implement both tests.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`:
- Around line 167-180: The findFolder function currently only checks top-level
folders and their immediate children, so it misses folders nested deeper; update
findFolder to perform a recursive search over MAILBOX_FOLDERS() (and each
folder.children array) so it descends any depth until predicate(folder) is true,
referencing the existing function name findFolder, the MAILBOX_FOLDERS() getter,
the Folder.children property, and the predicate parameter to guide where to
check and when to return the matching Folder or undefined.
- Line 151: The array of folder objects currently ends with "as const" which
removes compile-time validation against the Folder type; restore the original
type-safety by appending "satisfies readonly Folder[]" to that array definition
(or wrap the array in a typed helper function that returns readonly Folder[]) so
each entry is validated as a Folder while keeping literal inference for
properties like id; also keep the runtime casts currently used elsewhere (the
places casting to Folder) but they should become unnecessary once the array
satisfies readonly Folder[].

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`:
- Around line 58-62: The useMemo that computes the "title" (const title =
useMemo(...)) includes selectedMailbox in its dependency array but
selectedMailbox is not referenced inside the memo callback; remove
selectedMailbox from the dependency list so the memo only depends on
searchParams, folderSearchParams, labelsQuery.data?.data and t to avoid
unnecessary recomputations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a474171c-3250-456a-8eb1-f9f0ebee26e0

📥 Commits

Reviewing files that changed from the base of the PR and between d72df6c and 1f1f94f.

⛔ Files ignored due to path filters (10)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/src/features/api/gen/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/index.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_list_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/user_events_mark_read_partial_update200.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/user_events_mark_read_partial_update_body.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/user-events/user-events.ts is excluded by !**/gen/**
📒 Files selected for processing (27)
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/permissions.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/api/viewsets/user_event.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0024_userevent.py
  • src/backend/core/models.py
  • src/backend/core/signals.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/tests/api/test_thread_filter_mention.py
  • src/backend/core/tests/api/test_user_event.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/core/urls.py
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/index.tsx
  • src/frontend/src/features/message/use-mention-read.tsx

Comment thread src/backend/core/admin.py
Comment thread src/backend/core/api/openapi.json
Comment thread src/backend/core/api/viewsets/thread_event.py
Comment thread src/backend/core/api/viewsets/user_event.py Outdated
Comment thread src/backend/core/signals.py
Comment thread src/backend/core/tests/models/test_user_event.py Outdated
Comment thread src/frontend/src/features/layouts/components/thread-view/index.tsx Outdated
Comment thread src/frontend/src/features/message/use-mention-read.tsx Outdated
@jbpenrath jbpenrath force-pushed the feat/mention-event branch 3 times, most recently from 0f84ee8 to e88def3 Compare April 8, 2026 17:56
@jbpenrath jbpenrath marked this pull request as ready for review April 8, 2026 17:56
@jbpenrath jbpenrath requested a review from sylvinus April 8, 2026 17:56
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/backend/core/api/openapi.json (1)

5950-5965: ⚠️ Potential issue | 🟠 Major

stats_fields enum is incomplete and omits many valid fields documented and supported by the backend.

The OpenAPI schema declares only 6 enum values, but the backend's stats() method validates against 13 boolean fields (has_trashed, has_archived, has_draft, has_starred, has_attachments, has_sender, has_active, has_delivery_failed, has_delivery_pending, has_unread_mention, has_mention, is_spam, has_messages) plus 2 special fields (all, all_unread), and supports all of them with _unread suffixes. Tests confirm comma-separated combinations like "has_messages,has_trashed,has_draft,has_starred,has_sender" and "has_starred,has_starred_unread" work. Generated clients using this schema will incorrectly reject many valid requests. The root cause is THREAD_STATS_FIELDS_MAP in enums.py containing only 6 entries instead of all valid base fields—it should be expanded to match the valid_base_fields set in the stats() method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/openapi.json` around lines 5950 - 5965, The OpenAPI enum
for stats_fields is missing many valid values; update THREAD_STATS_FIELDS_MAP in
enums.py to include all base boolean fields used by stats() (has_trashed,
has_archived, has_draft, has_starred, has_attachments, has_sender, has_active,
has_delivery_pending, has_delivery_failed, is_spam, has_messages,
has_unread_mention, has_mention) and ensure the map and generation emit both
base and *_unread variants (plus the special values "all" and "all_unread") so
the OpenAPI enum for stats_fields matches the valid_base_fields set used in
stats(); regenerate the OpenAPI schema (openapi.json) so clients accept the same
comma-separated combinations the backend validates.
src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx (1)

279-289: ⚠️ Potential issue | 🔴 Critical

Gate the query on !!selectedMailbox and use optional chaining in the key.

enabled only prevents the fetch; it does not stop getThreadsStatsQueryKey(selectedMailbox!.id, ...) from being evaluated during render. If the sidebar mounts before mailbox selection resolves, this crashes before React Query can short-circuit. Use selectedMailbox?.id in the key and add !!selectedMailbox to the enabled condition.

This same issue also affects src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx.

Minimal fix
         query: {
-            enabled: folder.showStats,
-            queryKey: getThreadsStatsQueryKey(selectedMailbox!.id, queryParams),
+            enabled: folder.showStats && !!selectedMailbox,
+            queryKey: getThreadsStatsQueryKey(selectedMailbox?.id, queryParams),
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`
around lines 279 - 289, The query key and enabled flag for
useThreadsStatsRetrieve currently dereference selectedMailbox (via
getThreadsStatsQueryKey(selectedMailbox!.id, queryParams)) which can throw
before React Query checks enabled; update the options to use optional chaining
in the key (getThreadsStatsQueryKey(selectedMailbox?.id, queryParams)) and add a
truthy guard to enabled (enabled: !!selectedMailbox && folder.showStats) so the
key isn't evaluated when selectedMailbox is undefined; apply the same change
pattern in the analogous label-item usage.
♻️ Duplicate comments (1)
src/backend/core/api/viewsets/thread_event.py (1)

38-48: ⚠️ Potential issue | 🔴 Critical

Make sure HasThreadEditAccess is ThreadEvent-aware here.

Line 48 also covers update / partial_update / destroy, where DRF passes the fetched ThreadEvent to has_object_permission(). If that permission still expects a Thread, detail writes will fail for valid editors. A ThreadEvent-aware adapter permission is safer here.

#!/bin/bash
set -euo pipefail

python - <<'PY'
from pathlib import Path

for path in Path("src/backend").rglob("permissions.py"):
    text = path.read_text(encoding="utf-8")
    start = text.find("class HasThreadEditAccess")
    if start == -1:
        continue
    end = text.find("\nclass ", start + 1)
    print(f"== {path} ==")
    print(text[start:end if end != -1 else None])
PY

Expect HasThreadEditAccess.has_object_permission() either to accept a ThreadEvent directly or to delegate to obj.thread.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/thread_event.py` around lines 38 - 48, The
current get_permissions uses HasThreadEditAccess for object-level write actions
but HasThreadEditAccess.has_object_permission likely expects a Thread, causing
detail writes to fail when DRF passes a ThreadEvent; update HasThreadEditAccess
(the class and its has_object_permission method) to handle ThreadEvent objects
by checking if obj is a ThreadEvent and using obj.thread (or otherwise accepting
a ThreadEvent directly) so object permission checks for
update/partial_update/destroy succeed for valid editors while leaving
get_permissions (in ThreadEvent viewset) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/api/openapi.json`:
- Around line 6853-6856: The OpenAPI schema erroneously declares
count_unread_mentions as "type: string"; change both occurrences (the field at
the shown diff and the second block around lines 6922-6927) to a numeric type
(preferably "type: integer" with "format: int32") so generated clients receive a
numeric counter; this corresponds to the value computed in serializers.py (the
logic around count_unread_mentions using .count() in the serializer at
src/backend/core/api/serializers.py), so update the openapi.json entries for
count_unread_mentions to integer (and include format if desired).
- Around line 5766-5805: The OpenAPI operation
threads_events_read_mention_partial_update (path
"/api/v1.0/threads/{thread_id}/events/{id}/read-mention/") documents that a
missing ThreadEvent yields 404 but the responses object only lists 204; update
the OpenAPI spec by adding a 404 response entry under "responses" (e.g., "404":
{ "description": "ThreadEvent not found" } – optionally include an error
schema/ref used elsewhere) so the contract matches the description.

In `@src/backend/core/migrations/0024_alter_threadevent_type_userevent.py`:
- Around line 102-120: The migration creating the UserEvent model
(migrations.CreateModel for UserEvent) lacks a database-level uniqueness
constraint for the logical key (thread_event, user, type); add a
UniqueConstraint to the model options (Meta) for fields ['thread_event', 'user',
'type'] with a clear name (e.g., 'usrevt_event_user_type_uniq') so the database
enforces at-most-one UserEvent per (thread_event, user, type) and prevents
concurrent duplicate inserts during backfill/sync.
- Around line 34-57: The migration loop assumes event.data is a dict and
mentions is a list of dicts with a valid UUID "id"; normalize and validate
before using them: ensure event.data is a mapping and get mentions as a list,
then iterate mentions only if each item is a dict and has an "id" that parses to
a UUID (or is a non-empty string), collecting only those valid ids into
unique_user_ids and skipping malformed entries; then perform the MailboxAccess
query using that validated set (referencing the for event in queryset.iterator
loop, the mentions variable, seen_user_ids/unique_user_ids, and
MailboxAccess.objects.filter) so malformed historical payloads are ignored and
cannot abort the migration.

In `@src/backend/core/models.py`:
- Around line 1483-1497: Add a DB-level UniqueConstraint to the UserEvent model
Meta to enforce uniqueness of the logical mention tuple (user, thread, type) —
e.g., add models.UniqueConstraint(fields=["user", "thread", "type"],
name="usrevt_user_thread_type_uniq") inside the Meta.constraints for the
UserEvent class — and then update the sync/bulk_create path that creates
UserEvent rows to tolerate conflicts (e.g., use bulk_create with conflict/ignore
behavior or a DB upsert strategy) so concurrent syncs do not produce duplicate
rows.

In `@src/backend/core/tests/api/test_thread_filter_mention.py`:
- Around line 35-121: Add negative-case assertions to the tests so mentions
belonging to other users are ignored: create a second user (e.g., other_user)
and a UserEventFactory with user=other_user tied to one of the threads, then
assert that when calling threads-list with has_unread_mention and threads-stats
with stats_fields=has_unread_mention the other_user's mention is not returned or
counted; update test_filter_returns_threads_with_unread_mention,
test_filter_returns_empty_when_no_unread_mentions (add a thread with a mention
for other_user and assert it’s ignored), and
TestThreadStatsUnreadMention.test_stats_returns_correct_count accordingly, and
add an assertion that ThreadEvent.has_unread_mention (or the relevant model
property/method) does not consider the other_user’s mention when evaluated for
the authenticated user.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`:
- Around line 188-206: The current code reads/writes localStorage inside the
useState lazy initializer and inside toggleFolder, which breaks SSR/hydration;
change expandedFolders initialization to a safe in-memory default (e.g. { inbox:
true }) and remove localStorage access from the lazy initializer, then add a
useEffect that runs on the client to load and apply persisted state from
EXPANDED_FOLDERS_KEY (using typeof window !== 'undefined' or similar guard) via
setExpandedFolders; also update toggleFolder to guard writes to localStorage
(only call localStorage.setItem when window is defined) so reads/writes happen
only inside client-only code paths (useEffect and guarded toggleFolder) while
keeping the expandedFolders, setExpandedFolders, and toggleFolder signatures
unchanged.

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx`:
- Around line 14-25: The persisted filters returned by getStoredSelectedFilters
can include stale/invalid keys and must be validated against the allowed set
THREAD_PANEL_FILTER_PARAMS before being reused; update getStoredSelectedFilters
to parse localStorage (THREAD_SELECTED_FILTERS_KEY), ensure the parsed value is
an array of strings, filter that array to only include entries that exist in
THREAD_PANEL_FILTER_PARAMS (the valid param keys), and return that filtered
array, falling back to DEFAULT_SELECTED_FILTERS when parsing fails or no valid
entries remain so we don't render undefined labels or pass bogus params to
applyFilters().

In `@src/frontend/src/features/layouts/components/thread-view/index.tsx`:
- Around line 107-118: pendingMentionIdsRef currently only deduplicates within
each 150ms debounce window so the same data-event-id can be re-queued later and
cause duplicate optimistic PATCHes; change the logic to maintain a persistent
seen/inFlight set (e.g., seenMentionIds or inFlightMentionIds) alongside
pendingMentionIdsRef, add IDs to that persistent set when queued (in the
intersection observer callback), ensure flushPendingMentions and
debouncedFlushMentions read from pendingMentionIdsRef but do not remove IDs from
the persistent set until the markMentionsRead PATCH succeeds, and on PATCH
failure remove the batch IDs from the persistent set so they can be retried;
apply the same persistent-set guard to the other occurrence of this pattern (the
second pending-mention block).

In `@src/frontend/src/features/message/use-mention-read.tsx`:
- Around line 40-57: The optimistic stats update is currently using
prefix-matching query helpers and mutates all matching keys; change
queryClient.getQueriesData and queryClient.setQueriesData to the exact-match
helpers queryClient.getQueryData and queryClient.setQueryData, using the
existing statsQueryKey variable so only the base stats entry is read/updated
(adjust previousStats to use getQueryData and keep the same safe check and
Math.max decrement logic in the setQueryData updater).

In `@src/frontend/src/features/providers/mailbox.tsx`:
- Around line 112-139: The current implementation collapses all folder/filter
variants into the same search cache key causing stale reuse; update
getMailboxThreadsListQueryKey to include the other searchParams normalized but
only replace the search value with a normalized token (e.g. keep
params.toString() but with search param set to a fixed marker) instead of
returning just ['...','search'], and update getMailboxThreadsListSearchQueryKey
to mirror that normalization by returning
[...getMailboxThreadsListQueryKeyPrefix(mailboxId),
normalizedParamsWithSearchMarker] so the cleanup/reset targets the exact same
cache entry; use getMailboxThreadsListQueryKeyPrefix and the existing
ReadonlySearchParamsLike accessor methods to build the normalized param string.

---

Outside diff comments:
In `@src/backend/core/api/openapi.json`:
- Around line 5950-5965: The OpenAPI enum for stats_fields is missing many valid
values; update THREAD_STATS_FIELDS_MAP in enums.py to include all base boolean
fields used by stats() (has_trashed, has_archived, has_draft, has_starred,
has_attachments, has_sender, has_active, has_delivery_pending,
has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention) and
ensure the map and generation emit both base and *_unread variants (plus the
special values "all" and "all_unread") so the OpenAPI enum for stats_fields
matches the valid_base_fields set used in stats(); regenerate the OpenAPI schema
(openapi.json) so clients accept the same comma-separated combinations the
backend validates.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`:
- Around line 279-289: The query key and enabled flag for
useThreadsStatsRetrieve currently dereference selectedMailbox (via
getThreadsStatsQueryKey(selectedMailbox!.id, queryParams)) which can throw
before React Query checks enabled; update the options to use optional chaining
in the key (getThreadsStatsQueryKey(selectedMailbox?.id, queryParams)) and add a
truthy guard to enabled (enabled: !!selectedMailbox && folder.showStats) so the
key isn't evaluated when selectedMailbox is undefined; apply the same change
pattern in the analogous label-item usage.

---

Duplicate comments:
In `@src/backend/core/api/viewsets/thread_event.py`:
- Around line 38-48: The current get_permissions uses HasThreadEditAccess for
object-level write actions but HasThreadEditAccess.has_object_permission likely
expects a Thread, causing detail writes to fail when DRF passes a ThreadEvent;
update HasThreadEditAccess (the class and its has_object_permission method) to
handle ThreadEvent objects by checking if obj is a ThreadEvent and using
obj.thread (or otherwise accepting a ThreadEvent directly) so object permission
checks for update/partial_update/destroy succeed for valid editors while leaving
get_permissions (in ThreadEvent viewset) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a6e940d-2970-4b67-9eeb-05d0481c5504

📥 Commits

Reviewing files that changed from the base of the PR and between 1f1f94f and e88def3.

⛔ Files ignored due to path filters (8)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_list_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/thread-events/thread-events.ts is excluded by !**/gen/**
📒 Files selected for processing (39)
  • docs/env.md
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/permissions.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0024_alter_threadevent_type_userevent.py
  • src/backend/core/models.py
  • src/backend/core/signals.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/tests/api/test_thread_filter_mention.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/messages/settings.py
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/config/constants.ts
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/index.tsx
  • src/frontend/src/features/message/use-mention-read.tsx
  • src/frontend/src/features/providers/mailbox.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/frontend/src/hooks/use-visibility-observer.ts
✅ Files skipped from review due to trivial changes (4)
  • docs/env.md
  • src/frontend/src/features/config/constants.ts
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
🚧 Files skipped from review as they are similar to previous changes (12)
  • src/frontend/public/locales/common/en-US.json
  • src/backend/core/factories.py
  • src/backend/core/api/permissions.py
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/backend/core/admin.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/core/signals.py
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/serializers.py

Comment thread src/backend/core/api/openapi.json
Comment thread src/backend/core/api/openapi.json
Comment thread src/backend/core/migrations/0024_alter_threadevent_type_userevent.py Outdated
Comment thread src/backend/core/migrations/0024_alter_threadevent_type_userevent.py Outdated
Comment thread src/backend/core/models.py
Comment thread src/frontend/src/features/layouts/components/thread-view/index.tsx
Comment thread src/frontend/src/features/message/use-mention-read.tsx Outdated
Comment thread src/frontend/src/features/providers/mailbox.tsx Outdated
Comment thread docs/env.md Outdated
Comment thread src/backend/core/migrations/0024_alter_threadevent_type_userevent.py Outdated
Comment thread src/backend/core/models.py
@jbpenrath jbpenrath force-pushed the feat/mention-event branch 3 times, most recently from 202c798 to b03e883 Compare April 9, 2026 15:47
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/backend/core/api/viewsets/thread.py (2)

580-602: ⚠️ Potential issue | 🟠 Major

Keep the OpenSearch branch aligned with the normal list queryset.

get_queryset() now annotates events_count, but the OpenSearch fallback branch does not. That means /threads/ can return different serialized data depending on whether search is enabled, even for the same threads.

Please add the same Count("events", distinct=True) annotation here so both list paths expose the same fields.

💡 Suggested fix
                 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
                     ),
                     _has_unread_mention=Exists(
                         models.UserEvent.objects.filter(
                             thread=OuterRef("pk"),
                             user=request.user,
                             type=enums.UserEventTypeChoices.MENTION,
                             read_at__isnull=True,
                         )
                     ),
                     _has_mention=Exists(
                         models.UserEvent.objects.filter(
                             thread=OuterRef("pk"),
                             user=request.user,
                             type=enums.UserEventTypeChoices.MENTION,
                         )
                     ),
+                    events_count=Count("events", distinct=True),
                 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/thread.py` around lines 580 - 602, The
OpenSearch fallback branch's threads queryset is missing the same events_count
annotation added in get_queryset(), causing inconsistent serialized output;
update the threads Annotate call (where threads = threads.annotate(...)) to
include events_count=models.Count("events", distinct=True) so both the normal
get_queryset() path and the OpenSearch fallback expose the same events_count
field for serialization.

319-389: ⚠️ Potential issue | 🟠 Major

Handle mention _unread stats explicitly.

Because has_unread_mention and has_mention are whitelisted in valid_base_fields, requests like stats_fields=has_mention_unread now pass validation. The generic _unread branch on Lines 382-389 then builds Q(has_mention=True) / Q(has_unread_mention=True), but those are neither model fields nor queryset annotations, so this path can raise at aggregation time instead of returning a count.

Either reject those two _unread variants during validation, or map them to mention_condition & unread_condition / unread_mention_condition & unread_condition explicitly.

💡 Suggested fix
             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 == "has_unread_mention_unread":
+                aggregations[agg_key] = Count(
+                    "pk", filter=unread_mention_condition & unread_condition
+                )
+            elif field == "has_mention_unread":
+                aggregations[agg_key] = Count(
+                    "pk", filter=mention_condition & unread_condition
+                )
             elif field.endswith("_unread"):
                 base_field = field[:-7]
                 base_condition = Q(**{base_field: True})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/viewsets/thread.py` around lines 319 - 389, The
validation/aggregation logic allows fields like "has_mention_unread" to pass but
the generic field.endswith("_unread") branch builds Q(has_mention=True) which is
not a real model/annotation; fix by handling mention/unread combos explicitly:
in the aggregation loop (where aggregations is built and variables
unread_condition, mention_condition, unread_mention_condition exist) add
explicit branches for "has_mention_unread" -> Count("pk",
filter=mention_condition & unread_condition) and "has_unread_mention_unread" (if
needed) -> Count("pk", filter=unread_mention_condition & unread_condition), or
alternatively update the validation loop over requested_fields to reject
base_field values "has_mention" and "has_unread_mention" when used with the
"_unread" suffix so they never reach the generic field.endswith("_unread")
branch.
♻️ Duplicate comments (2)
src/frontend/src/features/message/use-mention-read.tsx (1)

45-61: ⚠️ Potential issue | 🟡 Minor

Guard against undefined selectedMailbox before cache invalidation.

The markMentionsRead callback uses selectedMailbox?.id at line 60, but if selectedMailbox is null/undefined, the query key becomes ['threads', undefined] which could invalidate unintended cache entries. The past review suggested adding an early return guard.

🛡️ Suggested fix
     const markMentionsRead = useCallback((threadEventIds: string[]) => {
-        if (!threadEventIds.length) return;
+        if (!threadEventIds.length || !selectedMailbox?.id) return;

         Promise.all(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/message/use-mention-read.tsx` around lines 45 - 61,
The markMentionsRead callback can call queryClient.invalidateQueries with an
undefined mailbox id when selectedMailbox is null/undefined; add a guard at the
start of markMentionsRead to return early if selectedMailbox is falsy (or if
selectedMailbox.id is undefined) so you only call
getMailboxThreadsListQueryKeyPrefix(selectedMailbox.id) when a valid mailbox
exists, and keep the existing invalidateThreadsStats call if that should always
run; update the logic around invalidateThreadsStats and
queryClient.invalidateQueries in markMentionsRead to only run the latter when
selectedMailbox?.id is present (reference markMentionsRead, selectedMailbox,
invalidateThreadsStats, queryClient.invalidateQueries,
getMailboxThreadsListQueryKeyPrefix).
src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx (1)

188-209: ⚠️ Potential issue | 🟠 Major

Move persisted folder-state loading out of the state initializer.

This still reads localStorage during render. The server path returns { inbox: true }, while the first client render can hydrate from persisted storage, so the markup can diverge before React hydrates. JSON.parse here can also crash the whole sidebar on malformed storage.

Initialize from an in-memory default, then load/persist inside useEffect with a small try/catch.

In Next.js App Router / React hydration, can reading `localStorage` in a `useState` initializer for a client component cause hydration mismatches when the server-rendered initial state differs from the client-rendered state?
💡 Suggested fix
-import { useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"

-    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>;
-    });
+    const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({
+        inbox: true,
+    });
+
+    useEffect(() => {
+        if (typeof window === "undefined") return;
+        try {
+            const savedState = window.localStorage.getItem(EXPANDED_FOLDERS_KEY);
+            if (savedState !== null) {
+                setExpandedFolders(JSON.parse(savedState) as Record<string, boolean>);
+            }
+        } catch {
+            window.localStorage.removeItem(EXPANDED_FOLDERS_KEY);
+        }
+    }, []);

     const toggleFolder = (folderId: string) => {
         setExpandedFolders((prev) => {
             const nextState = {
                 ...prev,
                 [folderId]: !prev[folderId],
             };
-            if (typeof window !== 'undefined') {
-                localStorage.setItem(EXPANDED_FOLDERS_KEY, JSON.stringify(nextState));
+            if (typeof window !== "undefined") {
+                window.localStorage.setItem(
+                    EXPANDED_FOLDERS_KEY,
+                    JSON.stringify(nextState),
+                );
             }
             return nextState;
         });
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`
around lines 188 - 209, The state initializer for expandedFolders currently
reads localStorage during render and JSON.parse can throw; change it to
initialize useState(() => ({ inbox: true })) only, then add a useEffect that
runs on mount to read EXPANDED_FOLDERS_KEY inside a try/catch, JSON.parse
safely, and call setExpandedFolders(parsed) if valid; keep toggleFolder (and
localStorage.setItem) as-is or alternatively move persistence to a separate
useEffect that writes localStorage whenever expandedFolders changes, but do not
perform any localStorage reads in the state initializer to avoid hydration
mismatches and parse crashes.
🧹 Nitpick comments (6)
src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx (1)

145-145: Redundant dependency in useMemo.

hasActiveFilters is derived from activeFilters, so including both in the dependency array is redundant. This doesn't cause bugs, but you could simplify it.

♻️ Suggested simplification
-    }, [hasActiveFilters, activeFilters, isSearch, threads?.count, t]);
+    }, [activeFilters, isSearch, threads?.count, t]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`
at line 145, Remove the redundant dependency hasActiveFilters from the
dependency array of the useMemo in thread-panel-header.tsx because it is derived
from activeFilters; update the dependency list to rely on activeFilters (and
keep isSearch, threads?.count, t) so the memoization is correct and not
duplicated.
src/backend/e2e/management/commands/e2e_demo.py (1)

532-547: Edge-case timing: event may still be editable at the exact boundary.

The is_editable() method uses <= comparison:

return timezone.now() - self.created_at <= timedelta(seconds=delay)

With past = timezone.now() - timedelta(seconds=delay), if checked immediately the event will be exactly at the boundary and is_editable() could still return True. For a more deterministic test, consider subtracting an extra second:

🛠️ Suggested adjustment
-        past = timezone.now() - timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY)
+        past = timezone.now() - timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY + 1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/e2e/management/commands/e2e_demo.py` around lines 532 - 547, The
test sets ThreadEvent.created_at to past = timezone.now() -
timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY) which can land exactly
on the is_editable() boundary (is_editable() uses <=), so change the past
calculation to subtract an extra second (e.g., subtract
timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY + 1)) before updating the
ThreadEvent timestamps so events are deterministically non-editable; update the
code that builds `past` and the subsequent
models.ThreadEvent.objects.filter(pk=event.pk).update(created_at=past,
updated_at=past) accordingly.
src/backend/core/tests/api/test_thread_event.py (1)

204-238: Please lock in the new author-only rule with a regression test.

HasThreadEditAccess now denies PATCH/DELETE when the caller is an editor on the thread but is not obj.author_id. The suite still only covers the author path and the no-access path, so a regression back to “any editor can edit/delete” would slip through.

Please add one PATCH and one DELETE case for a second editor on the same thread returning 403.

As per coding guidelines "Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases".

Also applies to: 396-423

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_thread_event.py` around lines 204 - 238, Add
regression tests to ensure HasThreadEditAccess denies non-author editors: in the
TestThreadEventUpdate class add one test method that creates a second user who
has editor access via setup_user_with_thread_access (but is not the ThreadEvent
author), authenticates that user with api_client.force_authenticate, then
attempts a PATCH to get_thread_event_url(thread.id, event.id) with updated data
and asserts response.status_code == status.HTTP_403_FORBIDDEN; add a second test
that does the same setup but sends a DELETE to the same URL and asserts a 403.
Use factories.ThreadEventFactory to create the event with author=user_a and
ensure the authenticated user is user_b (editor) so the checked behavior
exercises HasThreadEditAccess denying non-author editors.
src/backend/core/migrations/0025_alter_threadevent_type_userevent.py (1)

39-40: Add an index that matches the new mention lookup shape.

The hot queries added in src/backend/core/api/viewsets/thread.py filter UserEvent by thread, user, type, and sometimes read_at. The two separate indexes here only partially cover that predicate, so the inbox list/stats path will end up combining scans or post-filtering on what is now a frequent Exists(...) lookup.

A composite index keyed by (thread, user, type, read_at) or (thread, user, type) would fit those lookups much better.

As per coding guidelines "Implement database indexing and query optimization (Model Meta indexes, constraints)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/migrations/0025_alter_threadevent_type_userevent.py` around
lines 39 - 40, Add a composite index to the migration's 'indexes' list so the
hot lookup on UserEvent by thread, user, type (and optionally read_at) is
covered; in the same block where the existing Index entries
'usrevt_user_type_read' and 'usrevt_thread_type' are defined, append a new
models.Index with fields=['thread','user','type','read_at'] (or
fields=['thread','user','type'] if you prefer to omit read_at) and give it a
unique name like 'usrevt_thread_user_type_read' so the inbox Exists(...) queries
are supported by an index.
src/backend/core/api/openapi.json (1)

9050-9053: Consider adding descriptions for new exposed fields.

events_count and is_editable would benefit from short descriptions in generated API docs.

✍️ Suggested OpenAPI doc polish
                     "events_count": {
                         "type": "integer",
+                        "description": "Number of events currently attached to the thread.",
                         "readOnly": true
                     }
...
                     "is_editable": {
                         "type": "boolean",
+                        "description": "Whether the current user can still edit this thread event.",
                         "readOnly": true
                     },

Also applies to: 9289-9292

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/api/openapi.json` around lines 9050 - 9053, Add short
"description" entries to the exposed schema fields to improve generated API
docs: for the property "events_count" (currently with "type": "integer" and
"readOnly": true) add a concise description like "Total number of events
associated with this resource", and for "is_editable" add a description like
"Indicates whether this resource can be edited". Make the change in the same
schema objects where "events_count" and "is_editable" are defined (including the
other occurrence around lines 9289-9292) and keep existing attributes (type,
readOnly) intact.
src/backend/core/tests/api/test_thread_filter_mention.py (1)

35-79: Add explicit “read mention is excluded” cases for unread filter/stats.

has_unread_mention tests currently don’t seed a read mention and assert exclusion. If unread logic accidentally drifts to “any mention,” these tests could still pass.

✅ Suggested test additions
+    def test_filter_excludes_read_mentions(self, api_client):
+        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_unread_mention": "1"},
+        )
+
+        assert response.status_code == status.HTTP_200_OK
+        assert len(response.data["results"]) == 0
+
+    def test_stats_excludes_read_mentions(self, api_client):
+        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-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

As per coding guidelines, "Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases."

Also applies to: 107-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_thread_filter_mention.py` around lines 35 -
79, The tests test_filter_returns_threads_with_unread_mention and
test_filter_returns_empty_when_no_unread_mentions lack a case verifying that
read mentions are excluded; add a case that seeds a UserEvent with
type=enums.UserEventTypeChoices.MENTION linked to the same thread and user but
with read=True (or status indicating read according to UserEvent model) and
assert that when calling reverse("threads-list") with {"mailbox_id":
str(mailbox.id), "has_unread_mention": "1"} that thread is not returned;
similarly, add a test where only read mentions exist and assert the results are
empty to ensure the filter truly requires unread mentions (use
factories.UserEventFactory, ThreadEventFactory, and the existing
setup_user_with_thread_access helper to locate code).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/backend/core/api/viewsets/thread.py`:
- Around line 580-602: The OpenSearch fallback branch's threads queryset is
missing the same events_count annotation added in get_queryset(), causing
inconsistent serialized output; update the threads Annotate call (where threads
= threads.annotate(...)) to include events_count=models.Count("events",
distinct=True) so both the normal get_queryset() path and the OpenSearch
fallback expose the same events_count field for serialization.
- Around line 319-389: The validation/aggregation logic allows fields like
"has_mention_unread" to pass but the generic field.endswith("_unread") branch
builds Q(has_mention=True) which is not a real model/annotation; fix by handling
mention/unread combos explicitly: in the aggregation loop (where aggregations is
built and variables unread_condition, mention_condition,
unread_mention_condition exist) add explicit branches for "has_mention_unread"
-> Count("pk", filter=mention_condition & unread_condition) and
"has_unread_mention_unread" (if needed) -> Count("pk",
filter=unread_mention_condition & unread_condition), or alternatively update the
validation loop over requested_fields to reject base_field values "has_mention"
and "has_unread_mention" when used with the "_unread" suffix so they never reach
the generic field.endswith("_unread") branch.

---

Duplicate comments:
In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`:
- Around line 188-209: The state initializer for expandedFolders currently reads
localStorage during render and JSON.parse can throw; change it to initialize
useState(() => ({ inbox: true })) only, then add a useEffect that runs on mount
to read EXPANDED_FOLDERS_KEY inside a try/catch, JSON.parse safely, and call
setExpandedFolders(parsed) if valid; keep toggleFolder (and
localStorage.setItem) as-is or alternatively move persistence to a separate
useEffect that writes localStorage whenever expandedFolders changes, but do not
perform any localStorage reads in the state initializer to avoid hydration
mismatches and parse crashes.

In `@src/frontend/src/features/message/use-mention-read.tsx`:
- Around line 45-61: The markMentionsRead callback can call
queryClient.invalidateQueries with an undefined mailbox id when selectedMailbox
is null/undefined; add a guard at the start of markMentionsRead to return early
if selectedMailbox is falsy (or if selectedMailbox.id is undefined) so you only
call getMailboxThreadsListQueryKeyPrefix(selectedMailbox.id) when a valid
mailbox exists, and keep the existing invalidateThreadsStats call if that should
always run; update the logic around invalidateThreadsStats and
queryClient.invalidateQueries in markMentionsRead to only run the latter when
selectedMailbox?.id is present (reference markMentionsRead, selectedMailbox,
invalidateThreadsStats, queryClient.invalidateQueries,
getMailboxThreadsListQueryKeyPrefix).

---

Nitpick comments:
In `@src/backend/core/api/openapi.json`:
- Around line 9050-9053: Add short "description" entries to the exposed schema
fields to improve generated API docs: for the property "events_count" (currently
with "type": "integer" and "readOnly": true) add a concise description like
"Total number of events associated with this resource", and for "is_editable"
add a description like "Indicates whether this resource can be edited". Make the
change in the same schema objects where "events_count" and "is_editable" are
defined (including the other occurrence around lines 9289-9292) and keep
existing attributes (type, readOnly) intact.

In `@src/backend/core/migrations/0025_alter_threadevent_type_userevent.py`:
- Around line 39-40: Add a composite index to the migration's 'indexes' list so
the hot lookup on UserEvent by thread, user, type (and optionally read_at) is
covered; in the same block where the existing Index entries
'usrevt_user_type_read' and 'usrevt_thread_type' are defined, append a new
models.Index with fields=['thread','user','type','read_at'] (or
fields=['thread','user','type'] if you prefer to omit read_at) and give it a
unique name like 'usrevt_thread_user_type_read' so the inbox Exists(...) queries
are supported by an index.

In `@src/backend/core/tests/api/test_thread_event.py`:
- Around line 204-238: Add regression tests to ensure HasThreadEditAccess denies
non-author editors: in the TestThreadEventUpdate class add one test method that
creates a second user who has editor access via setup_user_with_thread_access
(but is not the ThreadEvent author), authenticates that user with
api_client.force_authenticate, then attempts a PATCH to
get_thread_event_url(thread.id, event.id) with updated data and asserts
response.status_code == status.HTTP_403_FORBIDDEN; add a second test that does
the same setup but sends a DELETE to the same URL and asserts a 403. Use
factories.ThreadEventFactory to create the event with author=user_a and ensure
the authenticated user is user_b (editor) so the checked behavior exercises
HasThreadEditAccess denying non-author editors.

In `@src/backend/core/tests/api/test_thread_filter_mention.py`:
- Around line 35-79: The tests test_filter_returns_threads_with_unread_mention
and test_filter_returns_empty_when_no_unread_mentions lack a case verifying that
read mentions are excluded; add a case that seeds a UserEvent with
type=enums.UserEventTypeChoices.MENTION linked to the same thread and user but
with read=True (or status indicating read according to UserEvent model) and
assert that when calling reverse("threads-list") with {"mailbox_id":
str(mailbox.id), "has_unread_mention": "1"} that thread is not returned;
similarly, add a test where only read mentions exist and assert the results are
empty to ensure the filter truly requires unread mentions (use
factories.UserEventFactory, ThreadEventFactory, and the existing
setup_user_with_thread_access helper to locate code).

In `@src/backend/e2e/management/commands/e2e_demo.py`:
- Around line 532-547: The test sets ThreadEvent.created_at to past =
timezone.now() - timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY) which
can land exactly on the is_editable() boundary (is_editable() uses <=), so
change the past calculation to subtract an extra second (e.g., subtract
timedelta(seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY + 1)) before updating the
ThreadEvent timestamps so events are deterministically non-editable; update the
code that builds `past` and the subsequent
models.ThreadEvent.objects.filter(pk=event.pk).update(created_at=past,
updated_at=past) accordingly.

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`:
- Line 145: Remove the redundant dependency hasActiveFilters from the dependency
array of the useMemo in thread-panel-header.tsx because it is derived from
activeFilters; update the dependency list to rely on activeFilters (and keep
isSearch, threads?.count, t) so the memoization is correct and not duplicated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a244905-aa1b-4c7f-89ab-d4e26301711a

📥 Commits

Reviewing files that changed from the base of the PR and between e88def3 and 202c798.

⛔ Files ignored due to path filters (8)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_list_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/thread-events/thread-events.ts is excluded by !**/gen/**
📒 Files selected for processing (45)
  • docs/env.md
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/permissions.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
  • src/backend/core/models.py
  • src/backend/core/signals.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/tests/api/test_thread_filter_mention.py
  • src/backend/core/tests/api/test_thread_split.py
  • src/backend/core/tests/conftest.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/messages/settings.py
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/e2e/src/__tests__/thread-starred-read.spec.ts
  • src/e2e/src/utils-test.ts
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/config/constants.ts
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/index.tsx
  • src/frontend/src/features/message/use-mention-read.tsx
  • src/frontend/src/features/providers/mailbox.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/frontend/src/hooks/use-visibility-observer.ts
✅ Files skipped from review due to trivial changes (11)
  • docs/env.md
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/config/constants.ts
  • src/e2e/src/utils-test.ts
  • src/backend/core/admin.py
  • src/backend/core/api/serializers.py
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/frontend/public/locales/common/fr-FR.json
  • src/backend/messages/settings.py
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/backend/core/tests/api/test_mailboxes.py
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/backend/core/factories.py
  • src/frontend/src/hooks/use-visibility-observer.ts
  • src/backend/core/enums.py
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts

@jbpenrath jbpenrath force-pushed the feat/mention-event branch 3 times, most recently from f1e0004 to 1b035d9 Compare April 9, 2026 21:15
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
src/frontend/src/features/layouts/components/thread-view/index.tsx (1)

111-126: ⚠️ Potential issue | 🟠 Major

Failed mention-read batches become non-retryable for the rest of the session.

These IDs are added to sentMentionIdsRef before the PATCH succeeds, and flushPendingMentions() clears the pending batch immediately. Because useMentionRead() swallows request failures, a transient error leaves the mention unread on the server but permanently suppressed from future retries until the user switches threads.

Also applies to: 182-188

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/layouts/components/thread-view/index.tsx` around
lines 111 - 126, The current flow marks IDs in sentMentionIdsRef before the
PATCH completes and clears pendingMentionIdsRef immediately in
flushPendingMentions, causing failed requests to be permanently suppressed;
change the logic so flushPendingMentions collects ids but does not move them
into sentMentionIdsRef or clear pendingMentionIdsRef until markMentionsRead
resolves successfully, and on failure leaves pendingMentionIdsRef intact (or
re-enqueues the ids) so debouncedFlushMentions/markMentionsRead can retry;
update the code paths around pendingMentionIdsRef, sentMentionIdsRef,
flushPendingMentions, debouncedFlushMentions and the call site of
markMentionsRead (and the useMentionRead hook behavior) to only mark as sent
after a successful PATCH and to handle transient errors by retrying or restoring
the pending set.
🧹 Nitpick comments (2)
src/backend/core/tests/api/test_mailboxes.py (1)

349-454: Add the shared-thread mailbox case.

This only validates disjoint threads. UserEvent is global to the (user, thread) pair, so one mentioned thread shared through mailbox_a and mailbox_b should increment both counters. Without that fixture, a mailbox-scoped implementation would still pass.

🧪 Suggested addition
+        # Shared thread visible from both mailboxes should count in both counters
+        shared_thread = factories.ThreadFactory()
+        factories.ThreadAccessFactory(
+            mailbox=mailbox_a,
+            thread=shared_thread,
+            role=enums.ThreadAccessRoleChoices.EDITOR,
+        )
+        factories.ThreadAccessFactory(
+            mailbox=mailbox_b,
+            thread=shared_thread,
+            role=enums.ThreadAccessRoleChoices.EDITOR,
+        )
+        shared_event = factories.ThreadEventFactory(thread=shared_thread, author=other_user)
+        factories.UserEventFactory(
+            user=user,
+            thread=shared_thread,
+            thread_event=shared_event,
+            type=enums.UserEventTypeChoices.MENTION,
+        )
+
         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
+        assert by_id[str(mailbox_a.id)]["count_unread_mentions"] == 3
+        assert by_id[str(mailbox_b.id)]["count_unread_mentions"] == 2

As per coding guidelines, "Unit tests should focus on a single use case, keep assertions minimal, and cover all possible cases."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/backend/core/tests/api/test_mailboxes.py` around lines 349 - 454, The
test test_list_count_unread_mentions is missing the case where a single thread
is shared across mailboxes: add a shared thread (e.g., thread_shared) and grant
ThreadAccessFactory for both mailbox_a and mailbox_b, create a ThreadEvent and
an unread UserEvent for user tied to that thread, then update the final
assertions to expect mailbox_a.count_unread_mentions == 3 and
mailbox_b.count_unread_mentions == 2 so the shared-thread increments both
mailboxes' counters; reference ThreadAccessFactory, ThreadEventFactory, and
UserEventFactory in the change.
src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx (1)

212-219: Consider adding aria-hidden to the invisible trigger element.

The hidden mention trigger span is purely for IntersectionObserver mechanics and has no semantic content. Adding aria-hidden="true" ensures screen readers skip it entirely.

♻️ Suggested improvement
                     {event.has_unread_mention && (
                         <span
                             className="thread-event__mention-trigger"
                             ref={mentionRef}
                             data-event-id={event.id}
                             style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
+                            aria-hidden="true"
                         />
                     )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`
around lines 212 - 219, The invisible mention trigger span used for
IntersectionObserver (rendered when event.has_unread_mention) should include
aria-hidden="true" so screen readers ignore it; update the span with className
"thread-event__mention-trigger" (the element using ref mentionRef and
data-event-id={event.id}) to add aria-hidden="true" while keeping its existing
props and inline styles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/backend/core/api/openapi.json`:
- Around line 4855-4862: The OpenAPI schema for boolean-like query flags such as
has_mention currently allows any integer; tighten the schema for the
"has_mention" query parameter (and the same params in /api/v1.0/threads/ and
/api/v1.0/threads/stats/) to constrain values to 0 or 1 by replacing the loose
integer type with a constrained schema (e.g., use an "enum": [0, 1] or
"minimum": 0 and "maximum": 1) so generated clients and validation only accept
the documented 0|1 values.

In `@src/backend/core/models.py`:
- Around line 1687-1695: The current UserEvent Meta indexes split predicates
across models.Index named "usrevt_user_type_read" and "usrevt_thread_type",
causing expensive Exists() subqueries; add a covering composite index on
UserEvent with fields ["user", "thread", "type", "read_at"] (e.g., name it
"usrevt_user_thread_type_read") in the UserEvent model's Meta.indexes so lookups
that filter by user+thread+type(+read_at) can use a single index for efficient
queries.

In `@src/frontend/public/locales/common/fr-FR.json`:
- Line 214: The JSON key "Collapse {{name}}" is missing its French translation,
causing blank UI labels; add an appropriate French string (e.g., "Réduire
{{name}}" or "Fermer {{name}}") as the value for the "Collapse {{name}}" key and
also fill the matching counterpart key (e.g., "Expand {{name}}") at the other
occurrence, making sure to keep the {{name}} placeholder intact and properly
localized.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx`:
- Line 47: The query key uses a non-null assertion selectedMailbox!.id which can
be undefined; change to optional chaining and add an enabled guard: use
selectedMailbox?.id when calling getThreadsStatsQueryKey and set the query's
enabled flag to Boolean(selectedMailbox?.id) (or similar) so the query only runs
when mailbox context is initialized; update the queryKey and the options for the
hook where queryKey: getThreadsStatsQueryKey(...) is used (reference
selectedMailbox, useMailboxContext, getThreadsStatsQueryKey, and the query's
enabled option).

In
`@src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx`:
- Around line 55-60: The code currently compares folder filters via full
querystring equality which fails with differing key order or extra unrelated
params; instead, parse both the folder.filter and the computed folderParams into
URLSearchParams and perform a robust match: treat the folder.filter params as
requirements and ensure every key in new URLSearchParams(folder.filter) has the
same set of values present in folderParams (use getAll and compare as
sets/arrays rather than string equality) when searching with findRootFolder;
update the matching logic around
findRootFolder/activeFolder/THREAD_PANEL_FILTER_PARAMS so folders are identified
by param-wise equivalence (subset match) not raw toString() equality.

---

Duplicate comments:
In `@src/frontend/src/features/layouts/components/thread-view/index.tsx`:
- Around line 111-126: The current flow marks IDs in sentMentionIdsRef before
the PATCH completes and clears pendingMentionIdsRef immediately in
flushPendingMentions, causing failed requests to be permanently suppressed;
change the logic so flushPendingMentions collects ids but does not move them
into sentMentionIdsRef or clear pendingMentionIdsRef until markMentionsRead
resolves successfully, and on failure leaves pendingMentionIdsRef intact (or
re-enqueues the ids) so debouncedFlushMentions/markMentionsRead can retry;
update the code paths around pendingMentionIdsRef, sentMentionIdsRef,
flushPendingMentions, debouncedFlushMentions and the call site of
markMentionsRead (and the useMentionRead hook behavior) to only mark as sent
after a successful PATCH and to handle transient errors by retrying or restoring
the pending set.

---

Nitpick comments:
In `@src/backend/core/tests/api/test_mailboxes.py`:
- Around line 349-454: The test test_list_count_unread_mentions is missing the
case where a single thread is shared across mailboxes: add a shared thread
(e.g., thread_shared) and grant ThreadAccessFactory for both mailbox_a and
mailbox_b, create a ThreadEvent and an unread UserEvent for user tied to that
thread, then update the final assertions to expect
mailbox_a.count_unread_mentions == 3 and mailbox_b.count_unread_mentions == 2 so
the shared-thread increments both mailboxes' counters; reference
ThreadAccessFactory, ThreadEventFactory, and UserEventFactory in the change.

In
`@src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx`:
- Around line 212-219: The invisible mention trigger span used for
IntersectionObserver (rendered when event.has_unread_mention) should include
aria-hidden="true" so screen readers ignore it; update the span with className
"thread-event__mention-trigger" (the element using ref mentionRef and
data-event-id={event.id}) to add aria-hidden="true" while keeping its existing
props and inline styles.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6969d17-15b0-4b25-b9f2-d544826c879d

📥 Commits

Reviewing files that changed from the base of the PR and between 202c798 and f1e0004.

⛔ Files ignored due to path filters (8)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_list_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/thread-events/thread-events.ts is excluded by !**/gen/**
📒 Files selected for processing (46)
  • docs/env.md
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/permissions.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
  • src/backend/core/models.py
  • src/backend/core/signals.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/tests/api/test_thread_filter_mention.py
  • src/backend/core/tests/api/test_thread_split.py
  • src/backend/core/tests/api/test_threads_list.py
  • src/backend/core/tests/conftest.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/messages/settings.py
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/e2e/src/__tests__/thread-starred-read.spec.ts
  • src/e2e/src/utils-test.ts
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/config/constants.ts
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/index.tsx
  • src/frontend/src/features/message/use-mention-read.tsx
  • src/frontend/src/features/providers/mailbox.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/frontend/src/hooks/use-visibility-observer.ts
✅ Files skipped from review due to trivial changes (10)
  • src/frontend/src/features/config/constants.ts
  • docs/env.md
  • src/e2e/src/tests/thread-starred-read.spec.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/backend/core/admin.py
  • src/e2e/src/tests/thread-event.spec.ts
🚧 Files skipped from review as they are similar to previous changes (15)
  • src/e2e/src/utils-test.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/backend/core/tests/conftest.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/frontend/src/features/message/use-mention-read.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx
  • src/frontend/src/hooks/use-visibility-observer.ts
  • src/backend/core/api/permissions.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
  • src/frontend/src/features/providers/mailbox.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx

Comment thread src/backend/core/api/openapi.json
Comment thread src/backend/core/models.py
Comment thread src/frontend/public/locales/common/fr-FR.json Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx (1)

188-193: ⚠️ Potential issue | 🟠 Major

Load persisted folder state after hydration, not during render.

The typeof window guard avoids the SSR crash, but this initializer still reads and parses localStorage on the first client render. If the saved value differs from the server default, the sidebar hydrates to different markup; malformed JSON will also throw during render. Start from a deterministic in-memory default and hydrate storage in a useEffect with a safe fallback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`
around lines 188 - 193, The state initializer for expandedFolders currently
reads localStorage during render (in the useState initializer), which risks SSR
hydration mismatch and JSON parse errors; change to initialize expandedFolders
to a deterministic default (e.g., {inbox: true}) and move the localStorage
read/parse into a useEffect that runs on mount: inside the effect, read
EXPANDED_FOLDERS_KEY, safely JSON.parse with try/catch, and call
setExpandedFolders to hydrate the stored state only on the client; ensure
malformed JSON falls back to the default and does not throw.
src/frontend/src/features/layouts/components/thread-view/index.tsx (1)

183-188: ⚠️ Potential issue | 🟠 Major

Only mark mention IDs as sent after the PATCH succeeds.

eventId is added to sentMentionIdsRef before the batch is flushed, and this component never removes it again if markMentionsRead() fails. One transient PATCH error therefore suppresses every retry for that mention until the thread is reopened.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/layouts/components/thread-view/index.tsx` around
lines 183 - 188, The code prematurely adds eventId to sentMentionIdsRef before
the batch PATCH succeeds; instead, only add IDs to sentMentionIdsRef after
markMentionsRead (or the function invoked by debouncedFlushMentions) resolves
successfully. Remove the sentMentionIdsRef.current.add(eventId) from the
observer callback, keep pendingMentionIdsRef.current.add(eventId) and call
debouncedFlushMentions(), then in the flush/markMentionsRead success handler
move the corresponding IDs from pendingMentionIdsRef to sentMentionIdsRef (and
remove them from pending); on error do not add to sentMentionIdsRef so retries
can occur.
🧹 Nitpick comments (1)
src/frontend/src/features/message/use-mention-read.tsx (1)

45-61: Guard against undefined selectedMailbox before invalidating.

When selectedMailbox?.id is undefined, the query key getMailboxThreadsListQueryKeyPrefix(undefined) will produce ['threads', undefined], which may not match intended queries or could cause unexpected cache behavior.

🛡️ Suggested fix
     const markMentionsRead = useCallback((threadEventIds: string[]) => {
-        if (!threadEventIds.length) return;
+        if (!threadEventIds.length || !selectedMailbox?.id) return;

         Promise.all(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/frontend/src/features/message/use-mention-read.tsx` around lines 45 - 61,
The invalidate step in markMentionsRead uses
getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) even when
selectedMailbox?.id may be undefined, which can create an unintended query key;
update markMentionsRead to guard before calling queryClient.invalidateQueries by
checking selectedMailbox and selectedMailbox.id (or computing the prefix and
validating it) and only call queryClient.invalidateQueries with
getMailboxThreadsListQueryKeyPrefix(selectedMailbox.id) when the mailbox id is
defined; keep invalidateThreadsStats() unchanged and ensure you reference the
existing functions/vars (markMentionsRead, selectedMailbox,
getMailboxThreadsListQueryKeyPrefix, queryClient.invalidateQueries) when making
the conditional guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/frontend/src/features/layouts/components/thread-view/index.tsx`:
- Around line 295-307: The condensed root's visible unread badge isn't receiving
a ref because mentionRef is gated by eventData.has_unread_mention instead of the
computed unread state; update the code around the ThreadEvent render so it
computes a hasUnread variable from unreadMentionGroupMap (e.g. const hasUnread =
unreadMentionGroupMap.get(item.data.id) ?? false) and pass that into both
hasUnreadMention and the mentionRef conditional (assigning
mentionRefs.current[item.data.id] when hasUnread is true) so condensed siblings
that surface the badge get a ref and can trigger markMentionsRead().

In `@src/frontend/src/features/providers/mailbox.tsx`:
- Around line 229-243: The hook currently returns null for selectedMailbox
during router hydration which allows consumers to dereference
selectedMailbox.id; change the logic in the selector (the function that computes
selectedMailbox using mailboxQuery.data and MailboxRoleChoices) to always return
a stable fallback mailbox (e.g. the last mailbox in mailboxQuery.data.data or a
prioritized role match) instead of null, and move or gate any redirect effect
that calls router.replace() so it only runs when router.isReady is true; update
references around selectedMailbox, router.isReady, mailboxQuery, and the
redirect effect to ensure selectedMailbox is never null during render and
redirects wait for router.isReady.

---

Duplicate comments:
In
`@src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx`:
- Around line 188-193: The state initializer for expandedFolders currently reads
localStorage during render (in the useState initializer), which risks SSR
hydration mismatch and JSON parse errors; change to initialize expandedFolders
to a deterministic default (e.g., {inbox: true}) and move the localStorage
read/parse into a useEffect that runs on mount: inside the effect, read
EXPANDED_FOLDERS_KEY, safely JSON.parse with try/catch, and call
setExpandedFolders to hydrate the stored state only on the client; ensure
malformed JSON falls back to the default and does not throw.

In `@src/frontend/src/features/layouts/components/thread-view/index.tsx`:
- Around line 183-188: The code prematurely adds eventId to sentMentionIdsRef
before the batch PATCH succeeds; instead, only add IDs to sentMentionIdsRef
after markMentionsRead (or the function invoked by debouncedFlushMentions)
resolves successfully. Remove the sentMentionIdsRef.current.add(eventId) from
the observer callback, keep pendingMentionIdsRef.current.add(eventId) and call
debouncedFlushMentions(), then in the flush/markMentionsRead success handler
move the corresponding IDs from pendingMentionIdsRef to sentMentionIdsRef (and
remove them from pending); on error do not add to sentMentionIdsRef so retries
can occur.

---

Nitpick comments:
In `@src/frontend/src/features/message/use-mention-read.tsx`:
- Around line 45-61: The invalidate step in markMentionsRead uses
getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id) even when
selectedMailbox?.id may be undefined, which can create an unintended query key;
update markMentionsRead to guard before calling queryClient.invalidateQueries by
checking selectedMailbox and selectedMailbox.id (or computing the prefix and
validating it) and only call queryClient.invalidateQueries with
getMailboxThreadsListQueryKeyPrefix(selectedMailbox.id) when the mailbox id is
defined; keep invalidateThreadsStats() unchanged and ensure you reference the
existing functions/vars (markMentionsRead, selectedMailbox,
getMailboxThreadsListQueryKeyPrefix, queryClient.invalidateQueries) when making
the conditional guard.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d42bbce3-afcc-4c8a-afea-b5869150afef

📥 Commits

Reviewing files that changed from the base of the PR and between f1e0004 and 1b035d9.

⛔ Files ignored due to path filters (8)
  • src/frontend/package-lock.json is excluded by !**/package-lock.json
  • src/frontend/src/features/api/gen/models/mailbox.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/thread_event.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_list_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts is excluded by !**/gen/**
  • src/frontend/src/features/api/gen/thread-events/thread-events.ts is excluded by !**/gen/**
📒 Files selected for processing (46)
  • docs/env.md
  • src/backend/core/admin.py
  • src/backend/core/api/openapi.json
  • src/backend/core/api/permissions.py
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/viewsets/thread_event.py
  • src/backend/core/enums.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
  • src/backend/core/models.py
  • src/backend/core/signals.py
  • src/backend/core/tests/api/test_mailboxes.py
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/tests/api/test_thread_filter_mention.py
  • src/backend/core/tests/api/test_thread_split.py
  • src/backend/core/tests/api/test_threads_list.py
  • src/backend/core/tests/conftest.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/messages/settings.py
  • src/e2e/src/__tests__/thread-event.spec.ts
  • src/e2e/src/__tests__/thread-starred-read.spec.ts
  • src/e2e/src/utils-test.ts
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/config/constants.ts
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/thread-view/index.tsx
  • src/frontend/src/features/message/use-mention-read.tsx
  • src/frontend/src/features/providers/mailbox.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/frontend/src/hooks/use-visibility-observer.ts
✅ Files skipped from review due to trivial changes (12)
  • docs/env.md
  • src/frontend/src/features/config/constants.ts
  • src/backend/messages/settings.py
  • src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss
  • src/e2e/src/tests/thread-starred-read.spec.ts
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx
  • src/frontend/public/locales/common/fr-FR.json
  • src/frontend/src/features/forms/components/search-filters-form/index.tsx
  • src/frontend/src/features/utils/view-helper/index.ts
  • src/backend/core/factories.py
  • src/backend/core/api/viewsets/thread.py
  • src/backend/core/api/openapi.json
🚧 Files skipped from review as they are similar to previous changes (16)
  • src/frontend/src/features/layouts/components/thread-panel/index.tsx
  • src/e2e/src/utils-test.ts
  • src/frontend/src/features/layouts/components/thread-view/components/thread-summary/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/_index.scss
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx
  • src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx
  • src/backend/core/tests/api/test_mailboxes.py
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss
  • src/backend/core/enums.py
  • src/backend/core/tests/conftest.py
  • src/backend/core/admin.py
  • src/backend/core/signals.py
  • src/e2e/src/tests/thread-event.spec.ts
  • src/frontend/src/hooks/use-visibility-observer.ts
  • src/backend/core/tests/api/test_thread_event.py
  • src/backend/core/migrations/0025_alter_threadevent_type_userevent.py
👮 Files not reviewed due to content moderation or server errors (6)
  • src/frontend/public/locales/common/en-US.json
  • src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss
  • src/backend/e2e/management/commands/e2e_demo.py
  • src/backend/core/models.py
  • src/backend/core/tests/models/test_user_event.py
  • src/backend/core/tests/api/test_threads_list.py

Comment thread src/frontend/src/features/layouts/components/thread-view/index.tsx
Comment thread src/frontend/src/features/providers/mailbox.tsx Outdated
@jbpenrath jbpenrath force-pushed the feat/mention-event branch 2 times, most recently from 27cf5b9 to 41c72e1 Compare April 9, 2026 22:06
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.
@jbpenrath jbpenrath force-pushed the feat/mention-event branch from 41c72e1 to bdc21e0 Compare April 9, 2026 22:11
@jbpenrath jbpenrath merged commit 1044614 into main Apr 9, 2026
13 checks passed
@jbpenrath jbpenrath deleted the feat/mention-event branch April 9, 2026 22:54
This was referenced May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants