mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Before: the feed could not tell which conversations had moved since the
reader last looked, so every card read the same.
After: opening an issue (page or peek) records a per-user read cursor after
1.5 s, posting a comment records it at once, and feed cards whose newest
counted comment is later than the cursor carry a small dot on the left: a
plain dot when the reader spoke last, a ringed one when someone else did
and the reply is theirs to make.
How: table issue_read_cursor (no primary key in the create, a CONCURRENTLY
built unique index on (user_id, issue_id) attached as the primary key in a
later migration, no foreign keys), queries UpsertIssueReadCursor (monotonic
GREATEST on the database clock), ListIssueReadCursors and the three
cleanup deletes wired into issue deletion, member removal and workspace
teardown. PUT /api/issues/{id}/read returns 204; thread-summaries gains
last_read_at for the caller. Core adds api.markIssueRead, useMarkIssueRead
and patchThreadSummariesLastRead; the comment-created patch keeps the
cursor. Views add getFeedReadState and the dot on FeedMessageCard, the
delayed mark in IssueDetail and the immediate mark on send in
useIssueTimeline.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
41 lines
1.8 KiB
SQL
41 lines
1.8 KiB
SQL
-- name: UpsertIssueReadCursor :exec
|
|
-- Monotonic: GREATEST keeps the newest value, so a late or replayed request
|
|
-- never moves a cursor backwards and re-marking an issue as read is a no-op.
|
|
-- A NULL last_read_at means "now" on the database clock, the same clock that
|
|
-- stamps comment.created_at, so API/DB clock skew cannot leave a just-read
|
|
-- issue looking unread.
|
|
INSERT INTO issue_read_cursor (user_id, issue_id, workspace_id, last_read_at)
|
|
VALUES (
|
|
sqlc.arg('user_id')::uuid,
|
|
sqlc.arg('issue_id')::uuid,
|
|
sqlc.arg('workspace_id')::uuid,
|
|
COALESCE(sqlc.narg('last_read_at')::timestamptz, now())
|
|
)
|
|
ON CONFLICT (user_id, issue_id) DO UPDATE
|
|
SET last_read_at = GREATEST(issue_read_cursor.last_read_at, EXCLUDED.last_read_at);
|
|
|
|
-- name: ListIssueReadCursors :many
|
|
-- The caller's cursors for a batch of issues, scoped to the workspace. Issues
|
|
-- the user never opened have no row.
|
|
SELECT issue_id, last_read_at
|
|
FROM issue_read_cursor
|
|
WHERE user_id = sqlc.arg('user_id')::uuid
|
|
AND workspace_id = sqlc.arg('workspace_id')::uuid
|
|
AND issue_id = ANY(sqlc.arg('issue_ids')::uuid[]);
|
|
|
|
-- name: DeleteIssueReadCursorsByIssue :exec
|
|
-- No foreign keys by project rule, so issue deletion cleans up here.
|
|
DELETE FROM issue_read_cursor
|
|
WHERE issue_id = sqlc.arg('issue_id')::uuid
|
|
AND workspace_id = sqlc.arg('workspace_id')::uuid;
|
|
|
|
-- name: DeleteIssueReadCursorsByUserWorkspace :exec
|
|
-- A departing member's cursors, in the same tx as the member-row delete.
|
|
DELETE FROM issue_read_cursor
|
|
WHERE user_id = sqlc.arg('user_id')::uuid
|
|
AND workspace_id = sqlc.arg('workspace_id')::uuid;
|
|
|
|
-- name: DeleteIssueReadCursorsForWorkspace :exec
|
|
-- No foreign keys by project rule, so workspace teardown cleans up here.
|
|
DELETE FROM issue_read_cursor WHERE workspace_id = sqlc.arg('workspace_id')::uuid;
|