From ba384e3dabe51320c288044679883f97b3aeeff5 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 1 Sep 2026 21:16:54 +0300 Subject: [PATCH] deliberi,web: notify invitees of offers standing/withdrawn Lewis: May this revision serve well! --- deliberi/bobbin.go | 53 ++++ deliberi/bobbin_test.go | 124 ++++++++ deliberi/db/db.go | 9 + deliberi/db/notifications.go | 19 +- deliberi/db/notifications_test.go | 83 +++++ deliberi/deliberi.go | 1 + deliberi/invites.go | 186 +++++++++++ deliberi/invites_test.go | 289 ++++++++++++++++++ deliberi/models/notifications.go | 44 +-- deliberi/xrpc/notifications.go | 11 + deliberi/xrpc/xrpc.go | 6 + deliberi/xrpc/xrpc_test.go | 86 ++++++ web/src/lib/api/notifications.test.ts | 34 +++ web/src/lib/api/notifications.ts | 7 + web/src/lib/auth/agent.ts | 11 + .../NotificationItem.stories.svelte | 14 + web/src/lib/components/notifications/mock.ts | 19 ++ .../notifications/notification.test.ts | 25 +- .../components/notifications/notification.ts | 21 +- web/src/lib/components/notifications/types.ts | 10 +- web/src/lib/components/repo/StarButton.svelte | 5 +- 21 files changed, 1026 insertions(+), 31 deletions(-) create mode 100644 deliberi/bobbin_test.go create mode 100644 deliberi/invites.go create mode 100644 deliberi/invites_test.go create mode 100644 web/src/lib/api/notifications.test.ts diff --git a/deliberi/bobbin.go b/deliberi/bobbin.go index 76e9d3657..835b03a63 100644 --- a/deliberi/bobbin.go +++ b/deliberi/bobbin.go @@ -10,6 +10,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/samber/lo" "tangled.org/core/api/org_tangled" "tangled.org/core/api/tangled" ) @@ -19,6 +20,25 @@ type recipientResolver interface { RepoOwner(ctx context.Context, repoDid string) (ownerDid, name string, err error) } +type inviteLister interface { + ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) + ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) +} + +type InviteOffer struct { + Uri string `json:"uri"` + KnotDid string `json:"knot"` + RepoDid string `json:"repo"` + AddedBy string `json:"addedBy"` + CreatedAt time.Time `json:"createdAt"` +} + +type InviteAnswer struct { + Offers []InviteOffer `json:"items"` + Pending []string `json:"pending"` + Truncated bool `json:"truncated"` +} + type bobbinClient struct { xc *indigoxrpc.Client } @@ -70,3 +90,36 @@ func (c *bobbinClient) RepoOwner(ctx context.Context, repoDid string) (string, s return owner, rec.Name, nil } + +const ( + knotListMemberInvitesByNSID = "sh.tangled.knot.listMemberInvitesBy" + repoListCollaboratorInvitesByNSID = "sh.tangled.repo.listCollaboratorInvitesBy" +) + +func (c *bobbinClient) ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { + return c.listInvitesBy(ctx, knotListMemberInvitesByNSID, subjectDid) +} + +func (c *bobbinClient) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { + return c.listInvitesBy(ctx, repoListCollaboratorInvitesByNSID, subjectDid) +} + +func (c *bobbinClient) listInvitesBy(ctx context.Context, nsid, subjectDid string) (InviteAnswer, error) { + var out InviteAnswer + params := map[string]any{"subject": subjectDid} + if err := c.xc.Do(ctx, indigoxrpc.Query, "", nsid, params, nil, &out); err != nil { + return InviteAnswer{}, fmt.Errorf("calling %s: %w", nsid, err) + } + + if blank, found := lo.Find(out.Offers, func(offer InviteOffer) bool { + return offer.Uri == "" || offer.KnotDid == "" + }); found { + return InviteAnswer{}, fmt.Errorf( + "%s served offer missing its %s, so later we couldn't tell whether it got withdrawn", + nsid, + lo.Ternary(blank.Uri == "", "record", "knot"), + ) + } + + return out, nil +} diff --git a/deliberi/bobbin_test.go b/deliberi/bobbin_test.go new file mode 100644 index 000000000..a58d263f6 --- /dev/null +++ b/deliberi/bobbin_test.go @@ -0,0 +1,124 @@ +package deliberi + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const memberInvitesByBody = `{"items":[{"uri":"at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` + +const collaboratorInvitesByBody = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","repo":"did:plc:scallop","addedBy":"did:plc:boltless","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` + +func serving(t *testing.T, path, body string) *bobbinClient { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != path { + t.Errorf("path = %q, want %q", r.URL.Path, path) + http.NotFound(w, r) + return + } + if r.URL.Query().Get("subject") == "" { + t.Error("subject param missing; bobbin requires it") + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + t.Cleanup(srv.Close) + return newBobbinClient(srv.URL) +} + +func TestListMemberInvitesByDecodesBobbinsBody(t *testing.T) { + c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, memberInvitesByBody) + + answer, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet") + if err != nil { + t.Fatalf("ListMemberInvitesBy: %v", err) + } + if len(answer.Offers) != 1 { + t.Fatalf("got %d offers, want 1", len(answer.Offers)) + } + want := InviteOffer{ + Uri: "at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet", + KnotDid: "did:web:knot.oyster.cafe", + AddedBy: "did:plc:akshay", + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + if answer.Offers[0] != want { + t.Fatalf("offer = %+v, want %+v", answer.Offers[0], want) + } + if len(answer.Pending) != 0 || answer.Truncated { + t.Fatalf("pending = %v, truncated = %v; complete answers leave both alone", answer.Pending, answer.Truncated) + } +} + +func TestListCollaboratorInvitesByDecodesBobbinsBody(t *testing.T) { + c := serving(t, "/xrpc/"+repoListCollaboratorInvitesByNSID, collaboratorInvitesByBody) + + answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet") + if err != nil { + t.Fatalf("ListCollaboratorInvitesBy: %v", err) + } + want := InviteOffer{ + Uri: "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet", + KnotDid: "did:web:knot.oyster.cafe", + RepoDid: "did:plc:scallop", + AddedBy: "did:plc:boltless", + CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + } + if len(answer.Offers) != 1 || answer.Offers[0] != want { + t.Fatalf("offers = %+v, want one %+v", answer.Offers, want) + } +} + +func TestPartialAnswerListsWhatBobbinCouldntRead(t *testing.T) { + const body = `{"items":[],"pending":["did:web:cold.knot","did:web:other.knot"],"truncated":true}` + c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + + answer, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet") + if err != nil { + t.Fatalf("ListMemberInvitesBy: %v", err) + } + if len(answer.Pending) != 2 { + t.Fatalf("pending = %v, want both knots", answer.Pending) + } + if !answer.Truncated { + t.Fatal("truncated dropped in decode, so we'd read cut list as whole offer set") + } +} + +func TestUnreadableCreatedAtFailsListing(t *testing.T) { + const body = `{"items":[{"uri":"at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:limpet","knot":"did:web:knot.example","addedBy":"did:plc:akshay","createdAt":"whenever"}]}` + c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + + if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + t.Fatal("we can't stamp rows from timestamps that won't read") + } +} + +func TestOfferMissingItsKnotFailsListing(t *testing.T) { + const body = `{"items":[{"uri":"at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:limpet","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}` + c := serving(t, "/xrpc/"+knotListMemberInvitesByNSID, body) + + if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + t.Fatal("offers missing their knot can't be checked against pending, so accepting one would make its absence look like withdrawal") + } +} + +func TestWarming503FailsListing(t *testing.T) { + const warming = `{"error":"Warming","message":"invite index is still warming, so it can't list offers yet"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprint(w, warming) + })) + t.Cleanup(srv.Close) + + c := newBobbinClient(srv.URL) + if _, err := c.ListMemberInvitesBy(context.Background(), "did:plc:limpet"); err == nil { + t.Fatal("503 decoded as empty listing, so reconcile would read every offer as withdrawn") + } +} diff --git a/deliberi/db/db.go b/deliberi/db/db.go index 6478bd4a6..3885104a2 100644 --- a/deliberi/db/db.go +++ b/deliberi/db/db.go @@ -87,6 +87,7 @@ create table if not exists notifications ( type text not null, actor_did text not null, repo_did text not null default '', + knot_did text not null default '', entity_at text not null default '', entity_title text not null default '', read integer not null default 0, @@ -210,6 +211,14 @@ func runMigrations(conn *sql.Conn, logger *slog.Logger) error { }); err != nil { return err } + if err := orm.RunMigration(conn, logger, "notification-knot-did", func(tx *sql.Tx) error { + if _, err := tx.Exec(`alter table notifications add column knot_did text not null default ''`); err != nil && !isColumnExistsErr(err) { + return err + } + return nil + }); err != nil { + return err + } return nil } diff --git a/deliberi/db/notifications.go b/deliberi/db/notifications.go index e3253421e..637cf1852 100644 --- a/deliberi/db/notifications.go +++ b/deliberi/db/notifications.go @@ -14,15 +14,19 @@ import ( // CreateNotification inserts a row, deduped on (recipient_did, at_uri). func CreateNotification(e Execer, n *models.Notification) error { + if n.Created.IsZero() { + n.Created = time.Now().UTC() + } query := ` insert into notifications - (recipient_did, at_uri, type, actor_did, repo_did, entity_at, entity_title, read) - values (?, ?, ?, ?, ?, ?, ?, ?) + (recipient_did, at_uri, type, actor_did, repo_did, knot_did, entity_at, entity_title, read, created) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict(recipient_did, at_uri) do nothing ` res, err := e.Exec(query, n.RecipientDid, n.AtUri, string(n.Type), n.ActorDid, - n.RepoDid, n.EntityAt, n.EntityTitle, n.Read, + n.RepoDid, n.KnotDid, n.EntityAt, n.EntityTitle, n.Read, + n.Created.UTC().Format(time.RFC3339), ) if err != nil { return fmt.Errorf("failed to create notification: %w", err) @@ -33,14 +37,14 @@ func CreateNotification(e Execer, n *models.Notification) error { return nil } -const notifCols = `id, recipient_did, at_uri, type, actor_did, repo_did, entity_at, entity_title, read, emailed, created` +const notifCols = `id, recipient_did, at_uri, type, actor_did, repo_did, knot_did, entity_at, entity_title, read, emailed, created` func scanNotification(rows interface{ Scan(...any) error }) (*models.Notification, error) { var n models.Notification var typeStr, createdStr string if err := rows.Scan( &n.ID, &n.RecipientDid, &n.AtUri, &typeStr, &n.ActorDid, - &n.RepoDid, &n.EntityAt, &n.EntityTitle, &n.Read, &n.Emailed, &createdStr, + &n.RepoDid, &n.KnotDid, &n.EntityAt, &n.EntityTitle, &n.Read, &n.Emailed, &createdStr, ); err != nil { return nil, err } @@ -96,6 +100,11 @@ func MarkRead(e Execer, recipientDid, atUri string, read bool) error { return err } +func DeleteNotification(e Execer, recipientDid, atUri string) error { + _, err := e.Exec(`delete from notifications where recipient_did = ? and at_uri = ?`, recipientDid, atUri) + return err +} + func MarkAllRead(e Execer, recipientDid string) error { _, err := e.Exec(`update notifications set read = 1 where recipient_did = ? and read = 0`, recipientDid) return err diff --git a/deliberi/db/notifications_test.go b/deliberi/db/notifications_test.go index 74dd06a7c..afd138727 100644 --- a/deliberi/db/notifications_test.go +++ b/deliberi/db/notifications_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "tangled.org/core/deliberi/models" "tangled.org/core/orm" @@ -73,3 +74,85 @@ func TestCreateNotificationDedupe(t *testing.T) { t.Fatalf("row count = %d, want 1 (deduped)", count) } } + +const inviteUri = "at://did:web:knot.example/sh.tangled.knot.memberInvite/did:plc:alice" + +func inviteOffer(recipientDid string) *models.Notification { + return &models.Notification{ + RecipientDid: recipientDid, + AtUri: inviteUri, + Type: models.NotificationTypeKnotInvited, + ActorDid: "did:plc:admin", + KnotDid: "did:web:knot.example", + } +} + +func TestDeleteNotificationIsolatedPerRecipient(t *testing.T) { + d := testDB(t) + + for _, did := range []string{"did:plc:alice", "did:plc:bob"} { + if err := CreateNotification(d, inviteOffer(did)); err != nil { + t.Fatalf("CreateNotification %s: %v", did, err) + } + } + + if err := DeleteNotification(d, "did:plc:alice", inviteUri); err != nil { + t.Fatalf("DeleteNotification: %v", err) + } + + aliceRows, _ := CountNotifications(d, "did:plc:alice") + bobRows, _ := CountNotifications(d, "did:plc:bob") + if aliceRows != 0 { + t.Fatalf("alice rows = %d, want 0", aliceRows) + } + if bobRows != 1 { + t.Fatalf("bob rows = %d, want 1 (alice's delete mustn't reach bob)", bobRows) + } +} + +func TestCreateAfterDeleteInserts(t *testing.T) { + d := testDB(t) + + if err := CreateNotification(d, inviteOffer("did:plc:alice")); err != nil { + t.Fatalf("first CreateNotification: %v", err) + } + if err := DeleteNotification(d, "did:plc:alice", inviteUri); err != nil { + t.Fatalf("DeleteNotification: %v", err) + } + if err := CreateNotification(d, inviteOffer("did:plc:alice")); err != nil { + t.Fatalf("second CreateNotification: %v", err) + } + + rows, err := GetNotifications(d, "did:plc:alice", 0) + if err != nil { + t.Fatalf("GetNotifications: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1 (re-offer must insert)", len(rows)) + } + if rows[0].KnotDid != "did:web:knot.example" { + t.Fatalf("knotDid = %q, want did:web:knot.example", rows[0].KnotDid) + } +} + +func TestCreateNotificationKeepsSuppliedCreated(t *testing.T) { + d := testDB(t) + made := time.Date(2026, 8, 24, 9, 30, 15, 0, time.UTC) + + offer := inviteOffer("did:plc:alice") + offer.Created = made + if err := CreateNotification(d, offer); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + + rows, err := GetNotifications(d, "did:plc:alice", 0) + if err != nil { + t.Fatalf("GetNotifications: %v", err) + } + if len(rows) != 1 { + t.Fatalf("row count = %d, want 1", len(rows)) + } + if !rows[0].Created.Equal(made) { + t.Fatalf("created = %s, want %s (offer's own timestamp)", rows[0].Created, made) + } +} diff --git a/deliberi/deliberi.go b/deliberi/deliberi.go index 4023ab366..9eac0a42d 100644 --- a/deliberi/deliberi.go +++ b/deliberi/deliberi.go @@ -54,6 +54,7 @@ func Run(ctx context.Context, cfg *config.Config) error { ServiceAuth: serviceAuth, IdResolver: resolver, Sender: sender, + Invites: NewInviteSync(database, bobbin, log.SubLogger(logger, "invites")), } if cfg.KvApiToken != "" && cfg.CloudflareAccountID != "" && cfg.KvNamespaceID != "" { diff --git a/deliberi/invites.go b/deliberi/invites.go new file mode 100644 index 000000000..5bc7c02ef --- /dev/null +++ b/deliberi/invites.go @@ -0,0 +1,186 @@ +package deliberi + +import ( + "context" + "log/slog" + "maps" + "sync" + "time" + + "github.com/samber/lo" + "golang.org/x/sync/errgroup" + deldb "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/models" + "tangled.org/core/orm" +) + +const ( + inviteReconcileInterval = 10 * time.Second + inviteFetchTimeout = 2 * time.Second +) + +var inviteNotificationTypes = []models.NotificationType{ + models.NotificationTypeKnotInvited, + models.NotificationTypeCollaboratorInvited, +} + +type InviteSync struct { + db *deldb.DB + invites inviteLister + logger *slog.Logger + + mu sync.Mutex + lastRun map[string]time.Time + swept time.Time +} + +func NewInviteSync(database *deldb.DB, invites inviteLister, logger *slog.Logger) *InviteSync { + return &InviteSync{ + db: database, + invites: invites, + logger: logger, + lastRun: map[string]time.Time{}, + } +} + +func (s *InviteSync) Reconcile(ctx context.Context, recipientDid string) { + if !s.claim(recipientDid) { + return + } + + answer, err := s.fetch(ctx, recipientDid) + if err != nil { + s.logger.Warn("listing invites, keeping stored rows", "err", err, "recipient", recipientDid) + return + } + + stored, err := deldb.GetNotifications(s.db, recipientDid, 0, orm.FilterIn("type", inviteNotificationTypes)) + if err != nil { + s.logger.Warn("reading stored invite rows", "err", err, "recipient", recipientDid) + return + } + + offered := keySet(answer.Offers, func(o InviteOffer) string { return o.Uri }) + unread := keySet(answer.Pending, func(knot string) string { return knot }) + held := keySet(stored, func(n *models.Notification) string { return n.AtUri }) + + withdrawn := lo.Filter(stored, func(n *models.Notification, _ int) bool { + _, standing := offered[n.AtUri] + _, cold := unread[n.KnotDid] + return !standing && !cold && !answer.Truncated + }) + fresh := lo.Map( + lo.Filter(answer.Offers, func(o InviteOffer, _ int) bool { + _, cached := held[o.Uri] + return !cached && o.AddedBy != recipientDid + }), + func(o InviteOffer, _ int) *models.Notification { + return inviteNotification(recipientDid, o) + }, + ) + + if err := s.settle(recipientDid, withdrawn, fresh); err != nil { + s.logger.Warn("writing reconciled invite rows", "err", err, "recipient", recipientDid) + } +} + +func (s *InviteSync) settle(recipientDid string, withdrawn, fresh []*models.Notification) error { + if len(withdrawn) == 0 && len(fresh) == 0 { + return nil + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := firstErr(withdrawn, func(n *models.Notification) error { + return deldb.DeleteNotification(tx, recipientDid, n.AtUri) + }); err != nil { + return err + } + if err := firstErr(fresh, func(n *models.Notification) error { + return deldb.CreateNotification(tx, n) + }); err != nil { + return err + } + + return tx.Commit() +} + +func (s *InviteSync) claim(recipientDid string) bool { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + + if now.Sub(s.swept) >= inviteReconcileInterval { + maps.DeleteFunc(s.lastRun, func(_ string, at time.Time) bool { + return now.Sub(at) >= inviteReconcileInterval + }) + s.swept = now + } + if at, seen := s.lastRun[recipientDid]; seen && now.Sub(at) < inviteReconcileInterval { + return false + } + s.lastRun[recipientDid] = now + return true +} + +func (s *InviteSync) fetch(ctx context.Context, recipientDid string) (InviteAnswer, error) { + ctx, cancel := context.WithTimeout(ctx, inviteFetchTimeout) + defer cancel() + + var members, collabs InviteAnswer + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + var err error + members, err = s.invites.ListMemberInvitesBy(gctx, recipientDid) + return err + }) + g.Go(func() error { + var err error + collabs, err = s.invites.ListCollaboratorInvitesBy(gctx, recipientDid) + return err + }) + if err := g.Wait(); err != nil { + return InviteAnswer{}, err + } + + return InviteAnswer{ + Offers: append(members.Offers, collabs.Offers...), + Pending: lo.Union(members.Pending, collabs.Pending), + Truncated: members.Truncated || collabs.Truncated, + }, nil +} + +func keySet[T any, K comparable](items []T, key func(T) K) map[K]struct{} { + return lo.SliceToMap(items, func(item T) (K, struct{}) { + return key(item), struct{}{} + }) +} + +func firstErr[T any](items []T, act func(T) error) error { + return lo.Reduce(items, func(err error, item T, _ int) error { + if err != nil { + return err + } + return act(item) + }, nil) +} + +func inviteNotification(recipientDid string, o InviteOffer) *models.Notification { + kind, repo := models.NotificationTypeKnotInvited, "" + if o.RepoDid != "" { + kind, repo = models.NotificationTypeCollaboratorInvited, o.RepoDid + } + return &models.Notification{ + RecipientDid: recipientDid, + AtUri: o.Uri, + Type: kind, + ActorDid: o.AddedBy, + KnotDid: o.KnotDid, + RepoDid: repo, + Created: o.CreatedAt, + } +} diff --git a/deliberi/invites_test.go b/deliberi/invites_test.go new file mode 100644 index 000000000..5594fbe6f --- /dev/null +++ b/deliberi/invites_test.go @@ -0,0 +1,289 @@ +package deliberi + +import ( + "context" + "errors" + "io" + "log/slog" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + deldb "tangled.org/core/deliberi/db" + models "tangled.org/core/deliberi/models" + "tangled.org/core/orm" +) + +const ( + invitee = "did:plc:invitee" + knotDid = "did:web:knot.example" + repoDid = "did:plc:repo" + memberUri = "at://" + knotDid + "/sh.tangled.knot.memberInvite/" + invitee + otherUri = "at://" + knotDid + "/sh.tangled.knot.memberInvite/did:plc:olaren" + collabUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/" + invitee +) + +var offerMade = time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + +type fakeLister struct { + members []InviteOffer + collabs []InviteOffer + pending []string + truncated bool + err error + calls *atomic.Int64 +} + +func (f *fakeLister) answer(offers []InviteOffer) (InviteAnswer, error) { + if f.calls != nil { + f.calls.Add(1) + } + if f.err != nil { + return InviteAnswer{}, f.err + } + return InviteAnswer{Offers: offers, Pending: f.pending, Truncated: f.truncated}, nil +} + +func (f *fakeLister) ListMemberInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { + return f.answer(f.members) +} + +func (f *fakeLister) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) { + return f.answer(f.collabs) +} + +func offerAt(uri, admin string) InviteOffer { + return InviteOffer{Uri: uri, KnotDid: knotDid, AddedBy: admin, CreatedAt: offerMade} +} + +func collabOffer(admin string) InviteOffer { + return InviteOffer{ + Uri: collabUri, + KnotDid: knotDid, + RepoDid: repoDid, + AddedBy: admin, + CreatedAt: offerMade, + } +} + +func newTestInviteSync(t *testing.T, l inviteLister) *InviteSync { + t.Helper() + database, err := deldb.Make(context.Background(), filepath.Join(t.TempDir(), "x.db")) + if err != nil { + t.Fatalf("make db: %v", err) + } + t.Cleanup(func() { database.Close() }) + return NewInviteSync(database, l, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func inviteRows(t *testing.T, s *InviteSync, did string) []*models.Notification { + t.Helper() + rows, err := deldb.GetNotifications(s.db, did, 0, orm.FilterIn("type", inviteNotificationTypes)) + if err != nil { + t.Fatalf("read invite rows: %v", err) + } + return rows +} + +func rowAt(t *testing.T, rows []*models.Notification, uri string) *models.Notification { + t.Helper() + for _, row := range rows { + if row.AtUri == uri { + return row + } + } + t.Fatalf("no row at %q", uri) + return nil +} + +func expire(s *InviteSync, did string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.lastRun, did) +} + +func TestReconcileCreatesRowFromOffer(t *testing.T) { + s := newTestInviteSync(t, &fakeLister{ + members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}, + collabs: []InviteOffer{collabOffer("did:plc:boltless")}, + }) + + s.Reconcile(context.Background(), invitee) + + rows := inviteRows(t, s, invitee) + if len(rows) != 2 { + t.Fatalf("row count = %d, want 2 (one per offer)", len(rows)) + } + + cases := []struct { + uri string + kind models.NotificationType + actor string + repo string + }{ + {memberUri, models.NotificationTypeKnotInvited, "did:plc:akshay", ""}, + {collabUri, models.NotificationTypeCollaboratorInvited, "did:plc:boltless", repoDid}, + } + + for _, tc := range cases { + row := rowAt(t, rows, tc.uri) + if row.Type != tc.kind { + t.Errorf("%s: type = %q, want %q", tc.uri, row.Type, tc.kind) + } + if row.ActorDid != tc.actor { + t.Errorf("%s: actorDid = %q, want inviting admin %q", tc.uri, row.ActorDid, tc.actor) + } + if row.RepoDid != tc.repo { + t.Errorf("%s: repoDid = %q, want %q", tc.uri, row.RepoDid, tc.repo) + } + if row.KnotDid != knotDid { + t.Errorf("%s: knotDid = %q, want knot serving this offer", tc.uri, row.KnotDid) + } + if !row.Created.Equal(offerMade) { + t.Errorf("%s: created = %s, want offer's own %s", tc.uri, row.Created, offerMade) + } + } +} + +func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) { + cases := []struct { + name string + then func(l *fakeLister) + want int + }{ + {"offer withdrawn", func(l *fakeLister) { l.members = nil }, 0}, + { + "offering knot pending", + func(l *fakeLister) { l.members, l.pending = nil, []string{knotDid} }, + 1, + }, + { + "answer truncated", + func(l *fakeLister) { + l.members, l.truncated = []InviteOffer{offerAt(otherUri, "did:plc:akshay")}, true + }, + 2, + }, + {"bobbin can't answer", func(l *fakeLister) { l.err = errors.New("bobbin unreachable") }, 1}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lister := &fakeLister{members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}} + s := newTestInviteSync(t, lister) + s.Reconcile(context.Background(), invitee) + if got := len(inviteRows(t, s, invitee)); got != 1 { + t.Fatalf("row count after offer = %d, want 1", got) + } + + tc.then(lister) + expire(s, invitee) + s.Reconcile(context.Background(), invitee) + + if got := len(inviteRows(t, s, invitee)); got != tc.want { + t.Fatalf("row count once %s = %d, want %d", tc.name, got, tc.want) + } + }) + } +} + +func TestReconcileDeletesOncePendingKnotIsRead(t *testing.T) { + lister := &fakeLister{ + members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}, + pending: []string{knotDid}, + } + s := newTestInviteSync(t, lister) + s.Reconcile(context.Background(), invitee) + + lister.members, lister.pending = nil, nil + expire(s, invitee) + s.Reconcile(context.Background(), invitee) + + if got := len(inviteRows(t, s, invitee)); got != 0 { + t.Fatalf("row count once bobbin has read knot = %d, want 0", got) + } +} + +func TestReconcileReofferAfterWithdrawal(t *testing.T) { + lister := &fakeLister{members: []InviteOffer{offerAt(memberUri, "did:plc:akshay")}} + s := newTestInviteSync(t, lister) + s.Reconcile(context.Background(), invitee) + + lister.members = nil + expire(s, invitee) + s.Reconcile(context.Background(), invitee) + + reoffer := offerAt(memberUri, "did:plc:akshay") + reoffer.CreatedAt = time.Date(2026, 8, 31, 9, 15, 0, 0, time.UTC) + lister.members = []InviteOffer{reoffer} + expire(s, invitee) + s.Reconcile(context.Background(), invitee) + + rows := inviteRows(t, s, invitee) + if len(rows) != 1 { + t.Fatalf("row count after re-offer = %d, want 1", len(rows)) + } + if !rows[0].Created.Equal(reoffer.CreatedAt) { + t.Fatalf("created = %s, want re-offer's %s", rows[0].Created, reoffer.CreatedAt) + } +} + +func TestReconcileSkipsSelfInvite(t *testing.T) { + s := newTestInviteSync(t, &fakeLister{members: []InviteOffer{offerAt(memberUri, invitee)}}) + + s.Reconcile(context.Background(), invitee) + + if got := len(inviteRows(t, s, invitee)); got != 0 { + t.Fatalf("row count for self-invite = %d, want 0", got) + } +} + +func TestReconcileAsksBobbinOncePerWindow(t *testing.T) { + var calls atomic.Int64 + s := newTestInviteSync(t, &fakeLister{calls: &calls}) + + var racers sync.WaitGroup + racers.Add(16) + for range 16 { + go func() { + defer racers.Done() + s.Reconcile(context.Background(), invitee) + }() + } + racers.Wait() + if got := calls.Load(); got != 2 { + t.Fatalf("listings across 16 concurrent bells = %d, want 2 (one pass, knot and repo)", got) + } + + expire(s, invitee) + s.Reconcile(context.Background(), invitee) + if got := calls.Load(); got != 4 { + t.Fatalf("listings once window passes = %d, want 4", got) + } +} + +func TestReconcileForgetsRecipientsPastWindow(t *testing.T) { + s := newTestInviteSync(t, &fakeLister{}) + + s.Reconcile(context.Background(), "did:plc:one") + s.Reconcile(context.Background(), "did:plc:two") + + s.mu.Lock() + stale := time.Now().Add(-2 * inviteReconcileInterval) + s.lastRun["did:plc:one"] = stale + s.swept = stale + s.mu.Unlock() + + s.Reconcile(context.Background(), "did:plc:three") + + s.mu.Lock() + defer s.mu.Unlock() + if _, held := s.lastRun["did:plc:one"]; held { + t.Fatal("recipient who didn't read this window is still held in debounce") + } + if _, held := s.lastRun["did:plc:two"]; !held { + t.Fatal("sweep took recipient still inside window") + } +} diff --git a/deliberi/models/notifications.go b/deliberi/models/notifications.go index cd195ed2a..648d16c30 100644 --- a/deliberi/models/notifications.go +++ b/deliberi/models/notifications.go @@ -12,22 +12,24 @@ import ( type NotificationType string const ( - NotificationTypeRepoStarred NotificationType = "repo_starred" - NotificationTypeIssueCreated NotificationType = "issue_created" - NotificationTypeIssueCommented NotificationType = "issue_commented" - NotificationTypePullCreated NotificationType = "pull_created" - NotificationTypePullCommented NotificationType = "pull_commented" - NotificationTypeFollowed NotificationType = "followed" - NotificationTypePullMerged NotificationType = "pull_merged" - NotificationTypeIssueClosed NotificationType = "issue_closed" - NotificationTypeIssueReopen NotificationType = "issue_reopen" - NotificationTypePullClosed NotificationType = "pull_closed" - NotificationTypePullReopen NotificationType = "pull_reopen" - NotificationTypeUserMentioned NotificationType = "user_mentioned" - NotificationTypeIssueAssigned NotificationType = "issue_assigned" - NotificationTypeIssueUnassigned NotificationType = "issue_unassigned" - NotificationTypePullAssigned NotificationType = "pull_assigned" - NotificationTypePullUnassigned NotificationType = "pull_unassigned" + NotificationTypeRepoStarred NotificationType = "repo_starred" + NotificationTypeIssueCreated NotificationType = "issue_created" + NotificationTypeIssueCommented NotificationType = "issue_commented" + NotificationTypePullCreated NotificationType = "pull_created" + NotificationTypePullCommented NotificationType = "pull_commented" + NotificationTypeFollowed NotificationType = "followed" + NotificationTypePullMerged NotificationType = "pull_merged" + NotificationTypeIssueClosed NotificationType = "issue_closed" + NotificationTypeIssueReopen NotificationType = "issue_reopen" + NotificationTypePullClosed NotificationType = "pull_closed" + NotificationTypePullReopen NotificationType = "pull_reopen" + NotificationTypeUserMentioned NotificationType = "user_mentioned" + NotificationTypeIssueAssigned NotificationType = "issue_assigned" + NotificationTypeIssueUnassigned NotificationType = "issue_unassigned" + NotificationTypePullAssigned NotificationType = "pull_assigned" + NotificationTypePullUnassigned NotificationType = "pull_unassigned" + NotificationTypeKnotInvited NotificationType = "knot_invited" + NotificationTypeCollaboratorInvited NotificationType = "collaborator_invited" ) var SocialNotificationTypes = []NotificationType{ @@ -50,6 +52,8 @@ var WorkNotificationTypes = []NotificationType{ NotificationTypeIssueUnassigned, NotificationTypePullAssigned, NotificationTypePullUnassigned, + NotificationTypeKnotInvited, + NotificationTypeCollaboratorInvited, } // email digest types; social types (repo_starred, followed) are excluded. @@ -77,6 +81,7 @@ type Notification struct { Type NotificationType ActorDid string RepoDid string + KnotDid string RepoName string // not stored; filled from the repo cache when the digest reads EntityAt string // related issue/pull at-uri (for links); source itself for issue/pull EntityTitle string @@ -109,6 +114,10 @@ func (n *Notification) Icon() string { return "user-round-arrow-forward" case NotificationTypeIssueUnassigned, NotificationTypePullUnassigned: return "user-round-minus" + case NotificationTypeKnotInvited: + return "server" + case NotificationTypeCollaboratorInvited: + return "users-round" default: return "" } @@ -122,7 +131,8 @@ func (n *Notification) URL(res *idresolver.Resolver) string { return did } - if n.Type == NotificationTypeFollowed { + switch n.Type { + case NotificationTypeFollowed, NotificationTypeKnotInvited: return "/" + resolve(n.ActorDid) } if n.RepoDid == "" || n.RepoName == "" { diff --git a/deliberi/xrpc/notifications.go b/deliberi/xrpc/notifications.go index 8caffee67..7c4d90c6b 100644 --- a/deliberi/xrpc/notifications.go +++ b/deliberi/xrpc/notifications.go @@ -22,6 +22,10 @@ func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { return } + if x.Invites != nil { + x.Invites.Reconcile(r.Context(), did) + } + q := r.URL.Query() filters := []orm.Filter{} if q.Get("read") == "unread" { @@ -65,6 +69,9 @@ func (x *Xrpc) NotificationList(w http.ResponseWriter, r *http.Request) { if n.RepoDid != "" { item.RepoDid = &n.RepoDid } + if n.KnotDid != "" && n.Type == models.NotificationTypeKnotInvited { + item.KnotDid = &n.KnotDid + } if n.EntityAt != "" { switch syntax.ATURI(n.EntityAt).Collection().String() { case "sh.tangled.repo.issue": @@ -92,6 +99,10 @@ func (x *Xrpc) NotificationGetUnreadCount(w http.ResponseWriter, r *http.Request return } + if x.Invites != nil { + x.Invites.Reconcile(r.Context(), did) + } + count, err := db.CountNotifications(x.DB, did, orm.FilterEq("read", 0)) if err != nil { l.Error("failed to count unread notifications", "err", err) diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go index a1f002254..d08df9bc3 100644 --- a/deliberi/xrpc/xrpc.go +++ b/deliberi/xrpc/xrpc.go @@ -1,6 +1,7 @@ package xrpc import ( + "context" "encoding/json" "log/slog" "net/http" @@ -28,6 +29,11 @@ type Xrpc struct { IdResolver *idresolver.Resolver Sender *mailer.Sender KV kv.EmailKV + Invites InviteReconciler +} + +type InviteReconciler interface { + Reconcile(ctx context.Context, recipientDid string) } func (x *Xrpc) Router() http.Handler { diff --git a/deliberi/xrpc/xrpc_test.go b/deliberi/xrpc/xrpc_test.go index d288b0286..89c2eec97 100644 --- a/deliberi/xrpc/xrpc_test.go +++ b/deliberi/xrpc/xrpc_test.go @@ -300,3 +300,89 @@ func TestMarkEntityReadPersists(t *testing.T) { t.Fatalf("unread after markEntityRead = %d, want 1 (only the unrelated one)", unread) } } + +func onlyNotification( + t *testing.T, + router http.Handler, + sign func(string) string, +) (*tangled.TempNotificationListNotifications_Notification, tangled.TempNotificationListNotifications_Output) { + t.Helper() + const nsid = "org.tangled.temp.notification.listNotifications" + req := httptest.NewRequest(http.MethodGet, "/"+nsid, nil) + 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()) + } + + var out tangled.TempNotificationListNotifications_Output + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if len(out.Notifications) != 1 { + t.Fatalf("got %d notifications, want 1", len(out.Notifications)) + } + return out.Notifications[0], out +} + +func TestNotificationListEmitsKnotDid(t *testing.T) { + router, d, sign := newTestXrpc(t) + + uri := "at://did:web:knot.example/sh.tangled.knot.memberInvite/" + testActor + if err := db.CreateNotification(d, &models.Notification{ + RecipientDid: testActor, + AtUri: uri, + Type: models.NotificationTypeKnotInvited, + ActorDid: "did:plc:akshay", + KnotDid: "did:web:knot.example", + }); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + + n, out := onlyNotification(t, router, sign) + if n.Type != string(models.NotificationTypeKnotInvited) { + t.Fatalf("type = %q, want knot_invited", n.Type) + } + if n.Uri != uri { + t.Fatalf("uri = %q, want invite record %q", n.Uri, uri) + } + if n.ActorDid != "did:plc:akshay" { + t.Fatalf("actorDid = %q, want inviting admin", n.ActorDid) + } + if n.KnotDid == nil || *n.KnotDid != "did:web:knot.example" { + t.Fatalf("knotDid = %v, want did:web:knot.example", n.KnotDid) + } + if n.RepoDid != nil { + t.Fatalf("repoDid = %v on a knot offer, want absent", *n.RepoDid) + } + if n.Category != "work" { + t.Fatalf("category = %q, want work", n.Category) + } + if out.WorkUnreadCount != 1 { + t.Fatalf("workUnreadCount = %d, want 1", out.WorkUnreadCount) + } +} + +func TestNotificationListHidesKnotOnCollaboratorOffer(t *testing.T) { + router, d, sign := newTestXrpc(t) + + if err := db.CreateNotification(d, &models.Notification{ + RecipientDid: testActor, + AtUri: "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/" + testActor, + Type: models.NotificationTypeCollaboratorInvited, + ActorDid: "did:plc:boltless", + RepoDid: "did:plc:scallop", + KnotDid: "did:web:knot.example", + }); err != nil { + t.Fatalf("CreateNotification: %v", err) + } + + n, _ := onlyNotification(t, router, sign) + if n.RepoDid == nil || *n.RepoDid != "did:plc:scallop" { + t.Fatalf("repoDid = %v, want did:plc:scallop", n.RepoDid) + } + if n.KnotDid != nil { + t.Fatalf("knotDid = %v, want absent; rows store it for reconcile alone", *n.KnotDid) + } +} diff --git a/web/src/lib/api/notifications.test.ts b/web/src/lib/api/notifications.test.ts new file mode 100644 index 000000000..253c266dc --- /dev/null +++ b/web/src/lib/api/notifications.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { hydrateNotifications, type NotificationItem } from "./notifications"; +import type { BobbinContext } from "./client"; + +const knotOffer: NotificationItem = { + uri: "at://did:web:knot.oyster.cafe/sh.tangled.knot.memberInvite/did:plc:limpet", + type: "knot_invited", + category: "work", + actorDid: "did:plc:akshay", + read: false, + createdAt: "2026-06-01T00:00:00.000Z", + knotDid: "did:web:knot.oyster.cafe" +}; + +const ctxWithoutBobbin = () => ({ xrpc: { call: vi.fn() } }) as unknown as BobbinContext; + +describe("hydrateNotifications", () => { + it.each([ + ["did:web:knot.oyster.cafe", "knot.oyster.cafe"], + ["did:web:knot.oyster.cafe%3A8443", "knot.oyster.cafe:8443"], + ["did:web:knot.oyster.cafe%zz", "did:web:knot.oyster.cafe%zz"] + ])("builds knot summary from row's own %s", async (knotDid, domain) => { + const rows = await hydrateNotifications(ctxWithoutBobbin(), [{ ...knotOffer, knotDid }]); + expect(rows[0].knot).toEqual({ did: knotDid, domain }); + }); + + it("leaves knot unset where row omits knotDid", async () => { + const { knotDid: _knotDid, ...withoutKnot } = knotOffer; + const rows = await hydrateNotifications(ctxWithoutBobbin(), [ + { ...withoutKnot, type: "followed" } + ]); + expect(rows[0].knot).toBeUndefined(); + }); +}); diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 4b0f2dd53..b3572475f 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -5,8 +5,10 @@ import { getReposByRepoDids } from "./records"; import { getIssues } from "./issue"; import { getPulls } from "./records"; import { didFromUri, rkeyFromUri } from "./uri"; +import { hostForServiceDid } from "$lib/auth/agent"; import type { NotificationActor, + NotificationKnot, NotificationRepo, NotificationSubject, NotificationSummary, @@ -52,6 +54,7 @@ export interface NotificationItem { read: boolean; createdAt: string; repoDid?: string; + knotDid?: string; issueAt?: string; pullAt?: string; } @@ -179,6 +182,9 @@ export const hydrateNotifications = async ( handle: item.actorDid }; const repo = item.repoDid ? repoMap.get(item.repoDid) : undefined; + const knot: NotificationKnot | undefined = item.knotDid + ? { did: item.knotDid, domain: hostForServiceDid(item.knotDid) ?? item.knotDid } + : undefined; let subject: NotificationSubject | undefined; if (item.issueAt) { subject = subjectFromUri(item.issueAt, "issue", titleMap.get(item.issueAt)); @@ -193,6 +199,7 @@ export const hydrateNotifications = async ( createdAt: item.createdAt, actor, repo, + knot, subject }; }); diff --git a/web/src/lib/auth/agent.ts b/web/src/lib/auth/agent.ts index f94302043..cd08fa614 100644 --- a/web/src/lib/auth/agent.ts +++ b/web/src/lib/auth/agent.ts @@ -17,6 +17,17 @@ export interface ServiceAuthOptions { // did:web service id, with ports percent-encoded like serviceauth didweb. export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, "%3A")}`; +export const hostForServiceDid = (did: string): string | null => { + if (!did.startsWith("did:web:")) return null; + const encoded = did.slice("did:web:".length); + if (encoded === "") return null; + try { + return decodeURIComponent(encoded); + } catch { + return null; + } +}; + // mint a service-auth jwt for knot/spindle xrpc calls. export const mintServiceAuth = async ( agent: OAuthUserAgent, diff --git a/web/src/lib/components/notifications/NotificationItem.stories.svelte b/web/src/lib/components/notifications/NotificationItem.stories.svelte index eec4520f2..76e77c4a6 100644 --- a/web/src/lib/components/notifications/NotificationItem.stories.svelte +++ b/web/src/lib/components/notifications/NotificationItem.stories.svelte @@ -8,6 +8,8 @@ const starred = notifications[2]; const merged = notifications[6]; const followed = notifications[4]; + const knotInvite = notifications[7]; + const collaboratorInvite = notifications[8]; const { Story } = defineMeta({ title: "Notifications/NotificationItem", @@ -53,6 +55,18 @@ {/snippet} + + {#snippet template(args)} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ {/snippet} +
+ {#snippet template()}
diff --git a/web/src/lib/components/notifications/mock.ts b/web/src/lib/components/notifications/mock.ts index 9cdb48483..859df3142 100644 --- a/web/src/lib/components/notifications/mock.ts +++ b/web/src/lib/components/notifications/mock.ts @@ -17,6 +17,7 @@ const dave = { did: "did:plc:kx4mzvhqrjpwqmvlbdxdlbnn", handle: "dave.tngl.sh" } const core = { ownerHandle: "tangled.org", name: "core" }; const knot = { ownerHandle: "tangled.org", name: "knot" }; +const knotHost = { did: "did:web:knot.tangled.sh", domain: "knot.tangled.sh" }; const subjectUri = (kind: "issue" | "pull", number: string) => `at://did:plc:mock/sh.tangled.repo.${kind}/${number}`; @@ -114,6 +115,24 @@ export const notifications: NotificationSummary[] = [ title: "appview: resolve handles inside the timeline feed" } }, + { + uri: `at://mock/notif/${16}`, + id: 16, + type: "knot_invited", + read: false, + createdAt: ago(60 * 33), + actor: alice, + knot: knotHost + }, + { + uri: `at://mock/notif/${17}`, + id: 17, + type: "collaborator_invited", + read: false, + createdAt: ago(60 * 36), + actor: bob, + repo: core + }, { uri: `at://mock/notif/${8}`, id: 8, diff --git a/web/src/lib/components/notifications/notification.test.ts b/web/src/lib/components/notifications/notification.test.ts index 0375c9848..f04422ac2 100644 --- a/web/src/lib/components/notifications/notification.test.ts +++ b/web/src/lib/components/notifications/notification.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { notificationHref } from "./notification"; +import { notificationHref, notificationTarget } from "./notification"; import type { NotificationSummary } from "./types"; const summary = (overrides: Partial = {}): NotificationSummary => ({ @@ -18,6 +18,13 @@ const summary = (overrides: Partial = {}): NotificationSumm ...overrides }); +const knotOffer = summary({ + type: "knot_invited", + knot: { did: "did:web:knot.test", domain: "knot.test" }, + repo: undefined, + subject: undefined +}); + describe("notificationHref", () => { it("links a named repo issue to its owner/repo path", () => { expect(notificationHref(summary())).toBe( @@ -40,4 +47,20 @@ describe("notificationHref", () => { it("falls back to the actor's page when there is no repo", () => { expect(notificationHref(summary({ repo: undefined }))).toBe("/alice.test"); }); + + it("links knot offer at admin who made it, because knot pages don't exist yet", () => { + expect(notificationHref(knotOffer)).toBe("/alice.test"); + }); + + it("links collaboration offer to its repository", () => { + expect( + notificationHref(summary({ type: "collaborator_invited", subject: undefined })) + ).toBe("/alice.test/core"); + }); +}); + +describe("notificationTarget", () => { + it("titles knot offer with knot's domain", () => { + expect(notificationTarget(knotOffer)).toBe("knot.test"); + }); }); diff --git a/web/src/lib/components/notifications/notification.ts b/web/src/lib/components/notifications/notification.ts index 95a4d6cc3..f32c14b75 100644 --- a/web/src/lib/components/notifications/notification.ts +++ b/web/src/lib/components/notifications/notification.ts @@ -7,9 +7,11 @@ import GitMerge from "$icon/git-merge"; import GitPullRequestClosed from "$icon/git-pull-request-closed"; import GitPullRequestCreate from "$icon/git-pull-request-create"; import MessageSquare from "$icon/message-square"; +import Server from "$icon/server"; import Star from "$icon/star"; import UserPlus from "$icon/user-plus"; import UserRoundMinus from "$icon/user-round-minus"; +import UsersRound from "$icon/users-round"; import UserRoundArrowForward from "$lib/components/icons/UserRoundArrowForward.svelte"; import type { NotificationCategory, @@ -38,7 +40,9 @@ const CATEGORIES: Record = { pull_reopen: "work", pull_assigned: "work", pull_unassigned: "work", - user_mentioned: "work" + user_mentioned: "work", + knot_invited: "work", + collaborator_invited: "work" }; const ICONS: Record = { @@ -57,7 +61,9 @@ const ICONS: Record = { pull_reopen: GitPullRequestCreate, pull_assigned: UserRoundArrowForward, pull_unassigned: UserRoundMinus, - user_mentioned: AtSign + user_mentioned: AtSign, + knot_invited: Server, + collaborator_invited: UsersRound }; // the design system has no purple, and a merged pull already reads as info on @@ -78,7 +84,9 @@ const TONES: Record = { pull_reopen: "text-foreground-success", pull_assigned: "text-foreground-info", pull_unassigned: "text-foreground-muted", - user_mentioned: "text-foreground-info" + user_mentioned: "text-foreground-info", + knot_invited: "text-foreground-info", + collaborator_invited: "text-foreground-info" }; const PHRASES: Record = { @@ -97,7 +105,9 @@ const PHRASES: Record = { pull_reopen: "reopened a PR on", pull_assigned: "assigned you to a PR on", pull_unassigned: "unassigned you from a PR on", - user_mentioned: "mentioned you in" + user_mentioned: "mentioned you in", + knot_invited: "invited you to", + collaborator_invited: "invited you to collaborate on" }; export const notificationCategory = (type: NotificationType): NotificationCategory => @@ -118,7 +128,8 @@ export const notificationPhrase = (notification: NotificationSummary): string => // the subject of the phrase, which the appview prints as owner/name after it export const notificationTarget = (notification: NotificationSummary): string => - notification.repo ? `${notification.repo.ownerHandle}/${notification.repo.name}` : ""; + notification.knot?.domain ?? + (notification.repo ? `${notification.repo.ownerHandle}/${notification.repo.name}` : ""); export const notificationHref = (notification: NotificationSummary): string => { if (notification.type === "followed") return `/${notification.actor.handle}`; diff --git a/web/src/lib/components/notifications/types.ts b/web/src/lib/components/notifications/types.ts index 39ba6777a..555066dac 100644 --- a/web/src/lib/components/notifications/types.ts +++ b/web/src/lib/components/notifications/types.ts @@ -18,7 +18,9 @@ export type NotificationType = | "pull_reopen" | "pull_assigned" | "pull_unassigned" - | "user_mentioned"; + | "user_mentioned" + | "knot_invited" + | "collaborator_invited"; export type NotificationCategory = "work" | "social"; @@ -36,6 +38,11 @@ export interface NotificationRepo { name: string; } +export interface NotificationKnot { + did: string; + domain: string; +} + export interface NotificationSubject { kind: "issue" | "pull"; uri: string; @@ -51,6 +58,7 @@ export interface NotificationSummary { createdAt: string; actor: NotificationActor; repo?: NotificationRepo; + knot?: NotificationKnot; subject?: NotificationSubject; } diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte index 143f17b00..68ce73c6b 100644 --- a/web/src/lib/components/repo/StarButton.svelte +++ b/web/src/lib/components/repo/StarButton.svelte @@ -111,6 +111,7 @@ const wasStarred = relation.active; const existingRkey = relation.rkey; const delta = wasStarred ? -1 : 1; + const undo = wasStarred ? 1 : -1; relation.begin(!wasStarred); starCount.resetFailure(); starCount.adjust(delta); @@ -127,9 +128,9 @@ } } catch { relation.fail(); - starCount.adjust(-delta); + starCount.adjust(undo); starCount.fail(); - profileCounts?.adjust(agent.sub, "stars", -delta); + profileCounts?.adjust(agent.sub, "stars", undo); } }; -- 2.51.2