diff --git a/api/tangled/notificationmarkEntityRead.go b/api/tangled/notificationmarkEntityRead.go new file mode 100644 index 000000000..8c6a952c1 --- /dev/null +++ b/api/tangled/notificationmarkEntityRead.go @@ -0,0 +1,30 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: org.tangled.temp.notification.markEntityRead + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + TempNotificationMarkEntityReadNSID = "org.tangled.temp.notification.markEntityRead" +) + +// TempNotificationMarkEntityRead_Input is the input argument to a org.tangled.temp.notification.markEntityRead call. +type TempNotificationMarkEntityRead_Input struct { + // entityAt: at-uri of the issue or pull whose notifications should be marked read. + EntityAt string `json:"entityAt" cborgen:"entityAt"` +} + +// TempNotificationMarkEntityRead calls the XRPC method "org.tangled.temp.notification.markEntityRead". +func TempNotificationMarkEntityRead(ctx context.Context, c util.LexClient, input *TempNotificationMarkEntityRead_Input) error { + if err := c.LexDo(ctx, util.Procedure, "application/json", "org.tangled.temp.notification.markEntityRead", nil, input, nil); err != nil { + return err + } + + return nil +} diff --git a/deliberi/db/notifications.go b/deliberi/db/notifications.go index ea1dd4f0a..e3253421e 100644 --- a/deliberi/db/notifications.go +++ b/deliberi/db/notifications.go @@ -101,6 +101,11 @@ func MarkAllRead(e Execer, recipientDid string) error { return err } +func MarkEntityRead(e Execer, recipientDid, entityAt string) error { + _, err := e.Exec(`update notifications set read = 1 where recipient_did = ? and entity_at = ? and read = 0`, recipientDid, entityAt) + return err +} + func MarkEmailed(e Execer, ids []int64) error { if len(ids) == 0 { return nil diff --git a/deliberi/xrpc/notifications.go b/deliberi/xrpc/notifications.go index 96daf7755..6152ad832 100644 --- a/deliberi/xrpc/notifications.go +++ b/deliberi/xrpc/notifications.go @@ -128,6 +128,33 @@ func (x *Xrpc) NotificationUpdateSeen(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (x *Xrpc) NotificationMarkEntityRead(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "NotificationMarkEntityRead") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempNotificationMarkEntityRead_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + if input.EntityAt == "" { + writeError(w, badRequestError("entityAt is required"), http.StatusBadRequest) + return + } + + if err := db.MarkEntityRead(x.DB, did, input.EntityAt); err != nil { + l.Error("failed to mark entity notifications read", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + func (x *Xrpc) NotificationMarkAllRead(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "NotificationMarkAllRead") diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go index 29b9f90f8..5911e64f0 100644 --- a/deliberi/xrpc/xrpc.go +++ b/deliberi/xrpc/xrpc.go @@ -48,6 +48,7 @@ func (x *Xrpc) Router() http.Handler { r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount) r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen) r.Post("/"+tangled.TempNotificationMarkAllReadNSID, x.NotificationMarkAllRead) + r.Post("/"+tangled.TempNotificationMarkEntityReadNSID, x.NotificationMarkEntityRead) r.Get("/"+tangled.TempNotificationGetPreferencesNSID, x.NotificationGetPreferences) r.Post("/"+tangled.TempNotificationUpdatePreferencesNSID, x.NotificationUpdatePreferences) diff --git a/deliberi/xrpc/xrpc_test.go b/deliberi/xrpc/xrpc_test.go index 633b9ae81..244778ac9 100644 --- a/deliberi/xrpc/xrpc_test.go +++ b/deliberi/xrpc/xrpc_test.go @@ -262,3 +262,36 @@ func TestUpdateSeenPersists(t *testing.T) { t.Fatalf("unread after updateSeen = %d, want 0", unread) } } + +func TestMarkEntityReadPersists(t *testing.T) { + router, d, sign := newTestXrpc(t) + + const entityAt = "at://did:plc:repo/sh.tangled.repo.issue/xyz" + + for _, n := range []*models.Notification{ + {RecipientDid: testActor, AtUri: entityAt, Type: models.NotificationTypeIssueCreated, ActorDid: "did:plc:someone", EntityAt: entityAt}, + {RecipientDid: testActor, AtUri: "at://did:plc:commenter/sh.tangled.feed.comment/abc", Type: models.NotificationTypeIssueCommented, ActorDid: "did:plc:commenter", EntityAt: entityAt}, + {RecipientDid: testActor, AtUri: "at://did:plc:repo/sh.tangled.repo.issue/other", Type: models.NotificationTypeIssueCreated, ActorDid: "did:plc:someone", EntityAt: "at://did:plc:repo/sh.tangled.repo.issue/other"}, + } { + if err := db.CreateNotification(d, n); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + } + + nsid := "org.tangled.temp.notification.markEntityRead" + req := httptest.NewRequest(http.MethodPost, "/"+nsid, strings.NewReader(`{"entityAt":"`+entityAt+`"}`)) + req.Header.Set("Authorization", "Bearer "+sign(nsid)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + unread, err := db.CountNotifications(d, testActor, orm.FilterEq("read", 0)) + if err != nil { + t.Fatalf("CountNotifications: %v", err) + } + if unread != 1 { + t.Fatalf("unread after markEntityRead = %d, want 1 (only the unrelated one)", unread) + } +} diff --git a/lexicons/temp/notification/markEntityRead.json b/lexicons/temp/notification/markEntityRead.json new file mode 100644 index 000000000..46dc52942 --- /dev/null +++ b/lexicons/temp/notification/markEntityRead.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "org.tangled.temp.notification.markEntityRead", + "defs": { + "main": { + "type": "procedure", + "description": "Mark all of the caller's notifications for a given issue or pull as read. Used when the user visits the entity directly, rather than through a notification.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["entityAt"], + "properties": { + "entityAt": { + "type": "string", + "format": "at-uri", + "description": "at-uri of the issue or pull whose notifications should be marked read." + } + } + } + } + } + } +} diff --git a/web/src/lib/components/repo/issues/IssueThreadView.svelte b/web/src/lib/components/repo/issues/IssueThreadView.svelte index ab8e59400..5b0749d07 100644 --- a/web/src/lib/components/repo/issues/IssueThreadView.svelte +++ b/web/src/lib/components/repo/issues/IssueThreadView.svelte @@ -30,6 +30,7 @@ import { deleteIssue, type IssueRecord } from "$lib/api/issue"; import type { RecordView } from "$lib/api/records"; import { getAuth } from "$lib/auth.svelte"; + import { getNotifications } from "$lib/notifications.svelte"; import CommentBox from "$lib/components/comment/CommentBox.svelte"; import CommentList from "$lib/components/comment/CommentList.svelte"; import type { CommentThread, ThreadInput } from "$lib/components/comment/comments"; @@ -59,6 +60,7 @@ let props: Props = $props(); const auth = getAuth(); + const notifications = getNotifications(); // local state for optimistic replies, re-synced when streamed props land let issue = $state(untrack(() => props.issue)); diff --git a/web/src/lib/notifications.svelte.ts b/web/src/lib/notifications.svelte.ts new file mode 100644 index 000000000..2f04a7267 --- /dev/null +++ b/web/src/lib/notifications.svelte.ts @@ -0,0 +1,25 @@ +import { getContext } from "svelte"; +import { createDeliberiClient } from "$lib/api/deliberi"; +import { markEntityNotificationsRead } from "$lib/api/notifications"; +import type { Auth } from "$lib/auth.svelte"; + +export const NOTIFICATIONS_KEY = Symbol("notifications"); + +export interface Notifications { + markEntityVisited(entityUri: string): Promise; +} + +export const createNotifications = (auth: Auth, deliberiUrl: string | undefined): Notifications => { + const markEntityVisited = async (entityUri: string): Promise => { + // yield first so the call lands outside the $effect that invoked it + await Promise.resolve(); + const agent = auth.agent; + if (!agent || !deliberiUrl) return; + const deliberi = createDeliberiClient({ deliberiUrl, agent }); + await markEntityNotificationsRead(deliberi, entityUri).catch(() => {}); + }; + + return { markEntityVisited }; +}; + +export const getNotifications = () => getContext(NOTIFICATIONS_KEY); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 697b76bd6..a4e3e9562 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import type { Did } from "@atcute/lexicons/syntax"; import { AuthorizationError } from "@atcute/oauth-browser-client"; import { createAuth, AUTH_KEY } from "$lib/auth.svelte"; + import { createNotifications, NOTIFICATIONS_KEY } from "$lib/notifications.svelte"; import { consumePendingCallback } from "$lib/auth/pending"; import Agentation from "$lib/components/dev/Agentation.svelte"; import Footer from "$lib/components/shell/Footer.svelte"; @@ -21,6 +22,9 @@ ); setContext(AUTH_KEY, auth); + const notifications = createNotifications(auth, data.publicConfig.deliberiUrl); + setContext(NOTIFICATIONS_KEY, notifications); + onMount(() => { if (page.url.pathname === "/oauth/callback") return; const pending = consumePendingCallback(); diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte index 25463f2b7..6f2f30ba5 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte @@ -1,4 +1,5 @@