feat: add message thread read receipts#956
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 145 |
| Duplication | 12 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
Greptile SummaryThis PR adds first-class read-receipt support for message threads: new
Confidence Score: 4/5Safe to merge. The watermark logic, GORM zero-value workaround, and split-update architecture are all sound. The two findings are edge-case efficiency and rare-race concerns that do not affect data correctness. The backend and frontend changes work correctly in the normal path. Two non-blocking issues were found: concurrent mark-read requests when a Pusher event fires while thread.is_read is already false in the store (cosmetic extra API call), and a narrow window in UpdateStatus where a concurrent thread deletion between the UPDATE and the reload SELECT could surface as a false 404. Neither affects stored data integrity or typical user flows. web/app/pages/threads/[id]/index.vue (duplicate concurrent mark-read calls) and api/pkg/services/message_thread_service.go (post-update reload TOCTOU). Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Phone as Mobile Phone
participant API as API Server
participant DB as PostgreSQL
participant Pusher as Pusher/WebSocket
participant Web as Web UI (Vue)
Phone->>API: POST /v1/messages/receive
API->>DB: LoadByOwnerContact (find thread)
API->>DB: UpdateActivity (order_timestamp, last_message_id, status)
Note over API,DB: Watermark check: WHERE last_read_at < event.Time()
DB-->>API: "UPDATE is_read=false (if watermark passes)"
API->>Pusher: Trigger message.phone.received
Pusher-->>Web: Push event
Web->>API: "PUT /v1/message-threads/{id} {is_read:true} force=true"
API->>DB: "UpdateStatus is_read=true last_read_at=now"
API->>DB: Load thread
API-->>Web: Updated thread (is_read:true)
Web-->>Web: replaceThread() update store
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Phone as Mobile Phone
participant API as API Server
participant DB as PostgreSQL
participant Pusher as Pusher/WebSocket
participant Web as Web UI (Vue)
Phone->>API: POST /v1/messages/receive
API->>DB: LoadByOwnerContact (find thread)
API->>DB: UpdateActivity (order_timestamp, last_message_id, status)
Note over API,DB: Watermark check: WHERE last_read_at < event.Time()
DB-->>API: "UPDATE is_read=false (if watermark passes)"
API->>Pusher: Trigger message.phone.received
Pusher-->>Web: Push event
Web->>API: "PUT /v1/message-threads/{id} {is_read:true} force=true"
API->>DB: "UpdateStatus is_read=true last_read_at=now"
API->>DB: Load thread
API-->>Web: Updated thread (is_read:true)
Web-->>Web: replaceThread() update store
Reviews (1): Last reviewed commit: "style(api): apply gofumpt to handler tes..." | Re-trigger Greptile |
Integrate current origin/main (thread archive-view preservation #952, unarchive-on-receive #954, webhook email payload formatting #955) with the read-receipts feature set, preserving both. Semantic resolutions: - UpdateThread now resolves the per-phone unarchive decision and the inbound unread watermark in a single atomic repository.UpdateActivity call. Added an Unarchive flag to MessageThreadActivityUpdate so is_archived=false is applied in the same transaction as the read-state/last-message updates. - Missed calls and inbound SMS both use MessageStatusReceived + MarksUnread, so they share the same unarchive + mark-unread rules. - Combined the message thread service test suites (read-receipt stubs plus shouldCheckUnarchive coverage) and updated the service/handler test helpers to the 5-arg NewMessageThreadService signature (adds phoneRepository). - Merged tests/README.md coverage entries for both E2E suites. - Regenerated Swagger and web/shared/types/api.ts so both main's new fields and read-receipt fields are present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Preserve read-receipt partial updates while incorporating webhook hardening and repository-wide Go vet fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Avoid duplicate realtime read updates and the post-write reload race. Retry asynchronous phone-key binding in integration tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
There was a problem hiding this comment.
Pull request overview
This PR implements message-thread read receipts across the Go API, Nuxt web UI, and Docker-backed tests/ integration suite. It adds a persisted per-thread is_read state with concurrency-safe partial updates, marks inbound SMS/missed calls unread, auto-marks opened threads read, and visually highlights unread threads in the thread list.
Changes:
- API: add
is_read(plus internallast_read_at) and extend the existing thread update endpoint to support optionalis_archived/is_readpartial updates, with listener-driven unread transitions for inbound SMS and missed calls. - Web: regenerate API types, add a Pinia
markThreadRead()workflow, auto-mark the open thread as read, and apply unread styling in the thread list. - Tests/docs: add an end-to-end read-receipts integration test and document the design + implementation plan.
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| web/shared/types/api.ts | Regenerated TS API contract to include is_read and optional thread update fields. |
| web/app/stores/threads.ts | Adds replaceThread() + markThreadRead() to persist and reflect read state in local store. |
| web/app/pages/threads/[id]/index.vue | Automatically marks the opened thread read; forces read update after inbound realtime events. |
| web/app/components/MessageThread.vue | Applies unread highlighting (bold + subtle primary-tint background). |
| tests/README.md | Documents read-receipts E2E coverage in the integration checklist. |
| tests/read_receipts_test.go | Adds Docker-stack E2E test for unread/read transitions and outbound preservation. |
| docs/superpowers/specs/2026-07-18-read-receipts-design.md | Captures the approved read-receipts design and concurrency rules. |
| docs/superpowers/plans/2026-07-18-read-receipts.md | Detailed implementation plan used to drive the feature work. |
| api/pkg/entities/message_thread.go | Adds persisted is_read and internal last_read_at fields with backward-compatible defaults. |
| api/pkg/entities/message_thread_test.go | Verifies read-field defaults via reflection. |
| api/pkg/requests/message_thread_update_request.go | Makes is_archived optional and adds optional is_read to the update request. |
| api/pkg/requests/message_thread_update_request_test.go | Ensures request-to-service params preserve optional field presence. |
| api/pkg/validators/message_thread_handler_validator.go | Requires at least one of is_archived or is_read on update payloads. |
| api/pkg/validators/message_thread_handler_validator_test.go | Tests empty payload rejection + read-only update acceptance. |
| api/pkg/responses/message_thead_responses.go | Adds single-thread Swagger response wrapper. |
| api/pkg/repositories/message_thread_repository.go | Introduces field-scoped update parameter types and new repository methods for atomic partial updates. |
| api/pkg/repositories/gorm_message_thread_repository.go | Implements atomic partial updates, conditional unread transition, and explicit-false persistence workaround for is_read. |
| api/pkg/repositories/gorm_message_thread_repository_test.go | Adds unit tests for update-map ownership and explicit unread persistence on create. |
| api/pkg/services/message_thread_service.go | Orchestrates new repository update flows; adds unread watermark parameters; supports partial status updates. |
| api/pkg/services/message_thread_service_test.go | Validates service-level unread/read rules and status-update behavior. |
| api/pkg/listeners/message_thread_listener.go | Marks inbound SMS and missed calls unread (and creates missed-call thread activity). |
| api/pkg/listeners/message_thread_listener_test.go | Tests listener sets MarksUnread and propagates event timestamps. |
| api/pkg/listeners/read_receipts_test_helpers_test.go | Provides listener test doubles for the new read-receipts behavior. |
| api/pkg/listeners/websocket_listener.go | Publishes message.call.missed via Pusher. |
| api/pkg/listeners/websocket_listener_test.go | Ensures missed-call websocket route is registered. |
| api/pkg/handlers/message_thread_handler.go | Updates Swagger response type and maps not-found update attempts to 404. |
| api/pkg/handlers/message_thread_handler_test.go | Tests 404 mapping for missing thread updates. |
| api/docs/docs.go | Regenerated Swagger output for the new thread contract and responses. |
| api/docs/swagger.json | Regenerated Swagger output for the new thread contract and responses. |
| api/docs/swagger.yaml | Regenerated Swagger output for the new thread contract and responses. |
Files not reviewed (1)
- api/docs/docs.go: Generated file
Use PostgreSQL RETURNING so concurrent activity cannot make status update responses stale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Fold the unread watermark into the activity update so inbound events use one database query. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Summary
tests/Validation
Note