From 8b6ef61f0079374f5a79fcb4e1e11bf4fc1feb84 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Wed, 19 Aug 2026 23:23:32 +0300 Subject: [PATCH] web: watch toggles, feed.subscription writes and notif prefs Signed-off-by: Anirudh Oppiliappan --- api/tangled/cbor_gen.go | 12 +-- api/tangled/feedsubscription.go | 28 ++--- lexicons/feed/subscription.json | 2 +- web/src/lib/api/graph.ts | 47 ++++++++ .../components/comment/CommentEditor.svelte | 5 + web/src/lib/components/repo/RepoHeader.svelte | 2 + .../components/repo/SubscribeButton.svelte | 101 ++++++++++++++++++ .../components/repo/issues/IssueForm.svelte | 9 ++ .../repo/issues/IssueThreadView.svelte | 67 ++++++++++++ .../components/repo/pulls/PullCompose.svelte | 6 +- web/src/lib/oauth-client-metadata.json | 2 +- .../[repo]/issues/[aturi]/+page.svelte | 3 +- .../[aturi]/[version]/[[range]]/+page.svelte | 2 +- .../[version]/[[range]]/PullViewPage.svelte | 72 ++++++++++++- 14 files changed, 330 insertions(+), 28 deletions(-) create mode 100644 web/src/lib/components/repo/SubscribeButton.svelte diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 2f597c25..3d1c7fe3 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -4254,10 +4254,10 @@ func (t *FeedSubscription) MarshalCBOR(w io.Writer) error { return err } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.subscription"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("org.tangled.feed.subscription"))); err != nil { return err } - if _, err := cw.WriteString(string("sh.tangled.feed.subscription")); err != nil { + if _, err := cw.WriteString(string("org.tangled.feed.subscription")); err != nil { return err } @@ -4519,10 +4519,10 @@ func (t *FeedSubscription_Uri) MarshalCBOR(w io.Writer) error { return err } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.subscription#uri"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("org.tangled.feed.subscription#uri"))); err != nil { return err } - if _, err := cw.WriteString(string("sh.tangled.feed.subscription#uri")); err != nil { + if _, err := cw.WriteString(string("org.tangled.feed.subscription#uri")); err != nil { return err } return nil @@ -4649,10 +4649,10 @@ func (t *FeedSubscription_Repo) MarshalCBOR(w io.Writer) error { return err } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.subscription#repo"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("org.tangled.feed.subscription#repo"))); err != nil { return err } - if _, err := cw.WriteString(string("sh.tangled.feed.subscription#repo")); err != nil { + if _, err := cw.WriteString(string("org.tangled.feed.subscription#repo")); err != nil { return err } return nil diff --git a/api/tangled/feedsubscription.go b/api/tangled/feedsubscription.go index 99f963d9..776e3d63 100644 --- a/api/tangled/feedsubscription.go +++ b/api/tangled/feedsubscription.go @@ -2,7 +2,7 @@ package tangled -// schema: sh.tangled.feed.subscription +// schema: org.tangled.feed.subscription import ( "bytes" @@ -15,26 +15,26 @@ import ( ) const ( - FeedSubscriptionNSID = "sh.tangled.feed.subscription" + FeedSubscriptionNSID = "org.tangled.feed.subscription" ) func init() { - util.RegisterType("sh.tangled.feed.subscription", &FeedSubscription{}) + util.RegisterType("org.tangled.feed.subscription", &FeedSubscription{}) } // // RECORDTYPE: FeedSubscription type FeedSubscription struct { - LexiconTypeID string `json:"$type,const=sh.tangled.feed.subscription" cborgen:"$type,const=sh.tangled.feed.subscription"` + LexiconTypeID string `json:"$type,const=org.tangled.feed.subscription" cborgen:"$type,const=org.tangled.feed.subscription"` // collections: Optional collection NSIDs to filter which notifications are sent. Empty or absent means all collections. Collections []string `json:"collections,omitempty" cborgen:"collections,omitempty"` CreatedAt string `json:"createdAt" cborgen:"createdAt"` Subject *FeedSubscription_Subject `json:"subject" cborgen:"subject"` } -// FeedSubscription_Repo is a "repo" in the sh.tangled.feed.subscription schema. +// FeedSubscription_Repo is a "repo" in the org.tangled.feed.subscription schema. // // RECORDTYPE: FeedSubscription_Repo type FeedSubscription_Repo struct { - LexiconTypeID string `json:"$type,const=sh.tangled.feed.subscription#repo" cborgen:"$type,const=sh.tangled.feed.subscription#repo"` + LexiconTypeID string `json:"$type,const=org.tangled.feed.subscription#repo" cborgen:"$type,const=org.tangled.feed.subscription#repo"` Did string `json:"did" cborgen:"did"` } @@ -45,11 +45,11 @@ type FeedSubscription_Subject struct { func (t *FeedSubscription_Subject) MarshalJSON() ([]byte, error) { if t.FeedSubscription_Uri != nil { - t.FeedSubscription_Uri.LexiconTypeID = "sh.tangled.feed.subscription#uri" + t.FeedSubscription_Uri.LexiconTypeID = "org.tangled.feed.subscription#uri" return json.Marshal(t.FeedSubscription_Uri) } if t.FeedSubscription_Repo != nil { - t.FeedSubscription_Repo.LexiconTypeID = "sh.tangled.feed.subscription#repo" + t.FeedSubscription_Repo.LexiconTypeID = "org.tangled.feed.subscription#repo" return json.Marshal(t.FeedSubscription_Repo) } return nil, fmt.Errorf("cannot marshal empty enum") @@ -61,10 +61,10 @@ func (t *FeedSubscription_Subject) UnmarshalJSON(b []byte) error { } switch typ { - case "sh.tangled.feed.subscription#uri": + case "org.tangled.feed.subscription#uri": t.FeedSubscription_Uri = new(FeedSubscription_Uri) return json.Unmarshal(b, t.FeedSubscription_Uri) - case "sh.tangled.feed.subscription#repo": + case "org.tangled.feed.subscription#repo": t.FeedSubscription_Repo = new(FeedSubscription_Repo) return json.Unmarshal(b, t.FeedSubscription_Repo) @@ -94,10 +94,10 @@ func (t *FeedSubscription_Subject) UnmarshalCBOR(r io.Reader) error { } switch typ { - case "sh.tangled.feed.subscription#uri": + case "org.tangled.feed.subscription#uri": t.FeedSubscription_Uri = new(FeedSubscription_Uri) return t.FeedSubscription_Uri.UnmarshalCBOR(bytes.NewReader(b)) - case "sh.tangled.feed.subscription#repo": + case "org.tangled.feed.subscription#repo": t.FeedSubscription_Repo = new(FeedSubscription_Repo) return t.FeedSubscription_Repo.UnmarshalCBOR(bytes.NewReader(b)) @@ -106,10 +106,10 @@ func (t *FeedSubscription_Subject) UnmarshalCBOR(r io.Reader) error { } } -// FeedSubscription_Uri is a "uri" in the sh.tangled.feed.subscription schema. +// FeedSubscription_Uri is a "uri" in the org.tangled.feed.subscription schema. // // RECORDTYPE: FeedSubscription_Uri type FeedSubscription_Uri struct { - LexiconTypeID string `json:"$type,const=sh.tangled.feed.subscription#uri" cborgen:"$type,const=sh.tangled.feed.subscription#uri"` + LexiconTypeID string `json:"$type,const=org.tangled.feed.subscription#uri" cborgen:"$type,const=org.tangled.feed.subscription#uri"` Uri string `json:"uri" cborgen:"uri"` } diff --git a/lexicons/feed/subscription.json b/lexicons/feed/subscription.json index e5e02c01..21856811 100644 --- a/lexicons/feed/subscription.json +++ b/lexicons/feed/subscription.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "sh.tangled.feed.subscription", + "id": "org.tangled.feed.subscription", "defs": { "main": { "type": "record", diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts index 40a2d702..9262f337 100644 --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -88,7 +88,54 @@ export const createStar = async (agent: OAuthUserAgent, repoDid: string): Promis export const deleteStar = async (agent: OAuthUserAgent, rkey: string): Promise => { await deleteRecord(agent, STAR_COLLECTION, rkey); }; +const SUBSCRIPTION_COLLECTION = "org.tangled.feed.subscription" as Nsid; +export const createSubscription = async (agent: OAuthUserAgent, repoDid: string): Promise => { + const { uri } = await createRecord(agent, SUBSCRIPTION_COLLECTION, { + $type: SUBSCRIPTION_COLLECTION, + subject: { + $type: "org.tangled.feed.subscription#repo", + did: repoDid as Did + }, + createdAt: new Date().toISOString() + }); + return rkeyFromUri(uri); +}; + +export const deleteSubscription = async (agent: OAuthUserAgent, rkey: string): Promise => { + await deleteRecord(agent, SUBSCRIPTION_COLLECTION, rkey); +}; + +export const createEntitySubscription = async (agent: OAuthUserAgent, entityUri: string): Promise => { + const { uri } = await createRecord(agent, SUBSCRIPTION_COLLECTION, { + $type: SUBSCRIPTION_COLLECTION, + subject: { + $type: "org.tangled.feed.subscription#uri", + uri: entityUri + }, + createdAt: new Date().toISOString() + }); + return rkeyFromUri(uri); +}; + +// 404 means no subscription exists, other errors propagate. +export const getSubscriptionRkey = async ( + ctx: BobbinContext, + actor: string, + subject: string +): Promise => { + try { + const { uri } = await jsonGet<{ uri: string }>( + ctx, + "org.tangled.feed.subscription.getForActor", + { actor: actor as Did, subject } + ); + return rkeyFromUri(uri); + } catch (cause) { + if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; + throw cause; + } +}; export type VouchKind = "vouch" | "denounce"; export interface VouchInput { diff --git a/web/src/lib/components/comment/CommentEditor.svelte b/web/src/lib/components/comment/CommentEditor.svelte index 6f045e54..e62f3a70 100644 --- a/web/src/lib/components/comment/CommentEditor.svelte +++ b/web/src/lib/components/comment/CommentEditor.svelte @@ -5,6 +5,7 @@ import type { Did } from "@atcute/lexicons/syntax"; import X from "$icon/x"; import { putComment } from "$lib/api/comment"; + import { createEntitySubscription } from "$lib/api/graph"; import { getAuth } from "$lib/auth.svelte"; import AccountSelector from "$lib/components/auth/AccountSelector.svelte"; import Button from "$lib/components/ui/Button.svelte"; @@ -93,6 +94,10 @@ record.replyTo = { uri: replyToUri, cid: replyToCid } as CommentRecord["replyTo"]; } const saved = await putComment(agent, targetRkey, record); + // auto-subscribe the commenter to notifications on the parent entity + createEntitySubscription(agent, subjectUri).catch((err) => { + console.error("Failed to auto-subscribe via comment:", err); + }); // render markdown client-side so the optimistic comment matches a real one const bodyHtml = await renderMarkup(body, markup).catch(() => null); onsubmitted?.({ diff --git a/web/src/lib/components/repo/RepoHeader.svelte b/web/src/lib/components/repo/RepoHeader.svelte index 600ef012..86b1d951 100644 --- a/web/src/lib/components/repo/RepoHeader.svelte +++ b/web/src/lib/components/repo/RepoHeader.svelte @@ -11,6 +11,7 @@ import uiLoadingprimitivesCount from "$lib/bones/ui-loadingprimitives--count.bones.json"; import StarButton from "./StarButton.svelte"; import type { RepoCounts, RepoInfo, RepoSource } from "./types"; + import SubscribeButton from "./SubscribeButton.svelte"; import { formatCount } from "$lib/format"; interface Props { repo: RepoInfo; @@ -35,6 +36,7 @@ initialCount={counts} initialRkey={viewerStarRkey} /> + + {/if} + + {#if error} + {error} + {/if} +{:else} + +{/if} diff --git a/web/src/lib/components/repo/issues/IssueForm.svelte b/web/src/lib/components/repo/issues/IssueForm.svelte index e04d6282..ecc68726 100644 --- a/web/src/lib/components/repo/issues/IssueForm.svelte +++ b/web/src/lib/components/repo/issues/IssueForm.svelte @@ -6,6 +6,7 @@ import Pencil from "$icon/pencil"; import X from "$icon/x"; import { putIssue } from "$lib/api/issue"; + import { createEntitySubscription } from "$lib/api/graph"; import { getAuth } from "$lib/auth.svelte"; import AccountSelector from "$lib/components/auth/AccountSelector.svelte"; import Button from "$lib/components/ui/Button.svelte"; @@ -83,11 +84,19 @@ createdAt: createdAt ?? new Date().toISOString() }; const saved = await putIssue(agent, targetRkey, record); + // auto-subscribe the creator to notifications on this issue (create only) + if (mode === "create") { + const entityUri = `at://${actingDid}/sh.tangled.repo.issue/${targetRkey}`; + createEntitySubscription(agent, entityUri).catch((err) => { + console.error("Failed to auto-subscribe to issue:", err); + }); + } onsaved?.(saved); }); const canSubmit = $derived(title.trim() !== "" && !save.loading); +