astro-atmosphere-comments 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -23
  3. package/components/AtmosphereComments.astro +179 -78
  4. package/components/AtmosphereReactions.astro +86 -51
  5. package/components/SignInForm.astro +14 -3
  6. package/components/atmosphere.css +21 -0
  7. package/index.mjs +42 -32
  8. package/lib/client/account.ts +553 -0
  9. package/lib/client/composer.ts +7 -10
  10. package/lib/http.ts +28 -0
  11. package/lib/permissions.ts +108 -0
  12. package/lib/public-settings.ts +18 -0
  13. package/lib/thread.ts +36 -6
  14. package/lib/uris.ts +31 -0
  15. package/locales/de.json +20 -4
  16. package/locales/en.json +20 -4
  17. package/locales/es.json +20 -4
  18. package/locales/fr.json +20 -4
  19. package/locales/it.json +20 -4
  20. package/locales/ja.json +20 -4
  21. package/locales/nl.json +20 -4
  22. package/locales/pt.json +20 -4
  23. package/package.json +2 -7
  24. package/routes/callback.astro +42 -0
  25. package/routes/client-metadata.json.ts +3 -2
  26. package/routes/following.ts +28 -0
  27. package/routes/identity.ts +69 -0
  28. package/routes/media.ts +7 -10
  29. package/routes/post.ts +1 -1
  30. package/routes/profile.ts +20 -0
  31. package/routes/richtext.ts +30 -0
  32. package/routes/thread.ts +23 -3
  33. package/routes/typeahead.ts +7 -23
  34. package/virtual.d.ts +5 -0
  35. package/bin/cli.mjs +0 -31
  36. package/lib/oauth.ts +0 -110
  37. package/lib/reader.ts +0 -130
  38. package/lib/store.ts +0 -46
  39. package/lib/visitor.ts +0 -42
  40. package/routes/callback.ts +0 -30
  41. package/routes/comment.ts +0 -97
  42. package/routes/delete.ts +0 -41
  43. package/routes/follow.ts +0 -71
  44. package/routes/jwks.json.ts +0 -13
  45. package/routes/login.ts +0 -58
  46. package/routes/logout.ts +0 -20
  47. package/routes/me.ts +0 -12
  48. package/routes/react.ts +0 -48
  49. package/routes/state.ts +0 -24
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (4 November 2026)
4
+
5
+ **Readers' sign-ins now stay in their own browsers.** This changes how the package works, so read "Upgrading" below.
6
+
7
+ - **Browser sign-in:** readers sign in as a browser ("public") OAuth client, with `@atproto/oauth-client-browser`. Their sign-in is kept in their browser's storage, with a key the browser won't let even the page's code read out, and comments, likes, reposts, deletes and follows go from their browser straight to their own account host. Your server never sees a sign-in, sets no cookies and stores nothing, so it has no way to post for anyone. Signing out cancels the sign-in at the reader's host and deletes it from their browser. Account hosts give browser sign-ins a shorter life (Bluesky's last about two weeks).
8
+ - **One approval for everything:** signing in asks for all that readers can do here at once (posting; liking and reposting; deleting their comments; following your accounts), plus hiding comments when the account is one of your own (`allowedAuthors`). Deleting and following were asked for separately before; with the sign-in in the browser, separate trips to the account host only confused readers. Permissions are grouped in `lib/permissions.ts`. Readers whose sign-in lacks a group (one from before it was added) are asked for it when it's first needed, after the page says what it's for, which account, and that their account host may ask them to sign in again first. `SignInForm` takes a `permissions` prop for anything more.
9
+ - **Hide comments:** signed in as the account that posted the announcement, a Hide button under other people's comments hides them here and on Bluesky (the announcement's threadgate, as the Bluesky app does). The first hide asks for that permission. Show them again in the Bluesky app. Fires `atmosphere:hidden` (`detail`: `uri`).
10
+ - **Respecting "logged-out" privacy:** accounts that use Bluesky's "Discourage apps from showing my account to logged-out users" are no longer shown on the page, which is public. Their comments become a short "Not shown here" placeholder (nothing about them reaches the page) with the replies under them kept, and they're left out of the like and repost faces. New wording key `comment.withheld`; style `.atmo-comment__withheld`.
11
+ - **Fewer third parties:** account lookups while signing in go through your server (`/atmosphere/identity`), so readers' browsers contact only their own account host.
12
+ - Under the sign-in button, a line says the sign-in stays in the browser, with a link to your privacy policy if you set `policyUri`.
13
+ - Routes: `login`, `logout`, `me`, `comment`, `react`, `delete`, `follow`, `state` and `jwks.json` are gone. New: `identity`, `profile`, `richtext` (links and mentions in a comment, before it's posted) and `following`; `callback` is now a page; `POST /atmosphere/thread` refreshes a thread after a reader posts. The `typeahead` limits are per address for everyone.
14
+ - The `keygen` command is gone: there's no key.
15
+ - New wording keys (`signIn.access`, `signIn.policy`, `composer.needsPermission`, `comment.hide…`, `comment.deleteElsewhere`, `permission.…`, `callback.signingIn`) in all eight languages; `messages.newPermissions` is gone and `messages.needsPermission` is now general.
16
+
17
+ ### Upgrading from 0.4
18
+
19
+ - Remove `ATMOSPHERE_PRIVATE_JWK` and `ATMOSPHERE_DATA_DIR` from your server's environment (and your deploy's secrets), and delete the data folder (default `.data/atmosphere`). The sign-ins in it can't be used without it: their tokens only work with keys kept in those files.
20
+ - **Readers will need to sign in again.** Their old sign-ins (made with the server key) may still be listed at their account host until they expire; they can remove them there.
21
+ - If you set a Content Security Policy, allow `connect-src` to readers' account hosts (any `https:` address), since the browser now talks to them directly.
22
+ - The site no longer needs a disk that persists.
23
+
3
24
  ## 0.4.2 (4 November 2026)
4
25
 
5
26
  - Links open in a new tab: those in the sign-in form, "Reply on Bluesky instead", the credit, and the thread's names, dates, faces and links in comments, so readers don't lose their place (and a half-written comment).
package/README.md CHANGED
@@ -10,6 +10,8 @@ Under each post, readers type their Bluesky handle (or that of any other [Atmosp
10
10
  - **@mention** people, with suggestions as they type;
11
11
  - **delete** their own comments.
12
12
 
13
+ And signed in as one of your own accounts, you can **hide** comments under your posts, here and on Bluesky.
14
+
13
15
  Everything they post is written to **their own account**, as ordinary Bluesky posts, likes and reposts: comments are replies to your post's announcement on Bluesky. They can delete them in any Bluesky app, and people who prefer the app can reply there instead; it all shows up on your page the same way.
14
16
 
15
17
  The component also **shows the thread**: who liked and reposted your post, and the comments, nested, with their own Reply, Like and Repost.
@@ -20,17 +22,17 @@ See it on [saltedlolly.com](https://saltedlolly.com/2005/07/13/summer-jam-party-
20
22
 
21
23
  1. You announce each post on Bluesky (a post linking to it). That post's thread is the comment section.
22
24
  2. A reader signs in with **AT Protocol OAuth, straight to their own account host** (Bluesky's, for most people), which asks them to approve your site, named by its address (example.com). There is no third-party login service in between, and your site never sees a password.
23
- 3. Your site asks only for what commenting needs: to create posts, to create and delete likes and reposts, and to see which posts the reader has already liked or reposted. Permission to delete posts is asked for separately, only the first time a reader deletes one of their comments on your site, and the site only ever uses it on their own comments under your posts. Permission to follow is asked for the same way, the first time a reader presses Follow, and only your own accounts can be followed through it. Readers can revoke it all at any time in their Bluesky settings.
24
- 4. Your server keeps each reader's sign-in (a random cookie in their browser; their tokens on your server), and posts on their behalf when they press a button.
25
+ 3. **The sign-in stays in the reader's browser.** Your server never sees it: comments, likes and reposts go from the reader's browser straight to their own account host. So your server has no way to post for anyone, a copy of your server's files holds nothing about your readers, and when a reader signs out, the sign-in is cancelled at their host and deleted from their browser. That's it: nothing is left anywhere.
26
+ 4. Your site asks, in one approval, only for what readers can do on it: to create posts, to like and repost, to delete their comments and to follow your accounts (and, when one of your own accounts signs in, to hide comments). Each is used only on your posts, the comments under them and your accounts. Readers can see and cancel it all at their account host.
25
27
 
26
28
  Readers who'd rather not sign in can use the **Reply on Bluesky instead** link under the form.
27
29
 
28
- Images (avatars, link-card pictures) and account searches (the suggestions under the sign-in box and for @mentions) go through your server, so readers' browsers never contact Bluesky directly.
30
+ Images (avatars, link-card pictures), account searches (the suggestions under the sign-in box and for @mentions) and account lookups when signing in go through your server, so readers' browsers contact only their own account host, never Bluesky's other servers.
29
31
 
30
32
  ## Requirements
31
33
 
32
34
  - **Astro 7** with an on-demand (server) adapter, such as [`@astrojs/node`](https://docs.astro.build/en/guides/integrations-guide/node/). The pages themselves can stay static; the integration adds a few server routes.
33
- - **Node 20.19 or later**, with a **disk that persists** between deploys and restarts: sign-ins are stored as files. (Serverless hosts without a lasting disk aren't supported yet.)
35
+ - **Node 20.19 or later.** Nothing is stored on your server (no sign-ins, no cookies, no files), so no disk or database is needed.
34
36
  - A **Bluesky account that announces your posts**. Each post needs its announcement's address, or a [Standard.site](https://standard.site) record that points to it (see below).
35
37
  - Your site's **`site`** set in `astro.config`.
36
38
 
@@ -64,17 +66,9 @@ export default defineConfig({
64
66
 
65
67
  To find an account's DID: `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=you.bsky.social`.
66
68
 
67
- ### 2. Create your site's signing key
68
-
69
- ```sh
70
- npx astro-atmosphere-comments keygen
71
- ```
72
-
73
- This adds `ATMOSPHERE_PRIVATE_JWK` to your `.env` without printing it. Your site signs its requests to readers' account hosts with it; the public half is published at `/atmosphere/jwks.json`, and the site's details for account hosts (its name, logo and permissions) at `/oauth-client-metadata.json`. That second file has to be at your site's root: it's the conventional address, and only then does Bluesky's approval page name your site by its address alone. Set the same variable on your server, keep `.env` out of Git, and keep a backup: **a new key signs every reader out.**
74
-
75
- On the server, also set `ATMOSPHERE_DATA_DIR` to a folder **outside your deploy folder** (for example `/var/lib/example-com/atmosphere`), so deploys don't sign readers out. It defaults to `.data/atmosphere` in the project.
69
+ The integration also publishes your site's details for account hosts (its name, logo and permissions) at `/oauth-client-metadata.json`. That file has to be at your site's root: it's the conventional address, and only then does Bluesky's approval page name your site by its address alone. There's no key or secret to set up.
76
70
 
77
- ### 3. Put the component under your posts
71
+ ### 2. Put the component under your posts
78
72
 
79
73
  ```astro
80
74
  ---
@@ -86,7 +80,7 @@ import AtmosphereComments from "astro-atmosphere-comments/AtmosphereComments.ast
86
80
 
87
81
  `post` can be the announcement's **bsky.app address** or its **AT URI**. Leave it out and the component uses the page's **Standard.site** record instead: if your page has `<link rel="site.standard.document" href="at://…">` and that record's `bskyPostRef` points to the announcement (as Standard.site publishers do), it just works. With no announcement to respond to, the component shows nothing (apart from your slot content).
88
82
 
89
- ### 4. Optional: likes and reposts on your other Bluesky posts
83
+ ### 3. Optional: likes and reposts on your other Bluesky posts
90
84
 
91
85
  If your pages show your Bluesky posts elsewhere (a "latest posts" feed on your homepage, say), readers can like and repost them there too. Put `AtmosphereReactions` once in your layout, and mark each post's element:
92
86
 
@@ -109,7 +103,7 @@ import AtmosphereReactions from "astro-atmosphere-comments/AtmosphereReactions.a
109
103
 
110
104
  Each marked post gets a row of icons: Reply, Repost, Quote and Like, with counts for Repost and Like. Readers already signed in on your site like and repost straight away, and Reply and Quote open the comment box in an overlay (with the quote preview); what they post goes to Bluesky, not your page, and the overlay says so with a link to it. Anyone else is offered the sign-in form in an overlay when they press Like or Repost (their like or repost is done when they come back), and Reply and Quote open the post on Bluesky. It works with posts from loaders such as [`@ascorbic/bluesky-loader`](https://www.npmjs.com/package/@ascorbic/bluesky-loader) or embeds such as [`@astro-community/astro-embed-bluesky`](https://www.npmjs.com/package/@astro-community/astro-embed-bluesky) (wrap the embed in the marked element). Only posts by your `allowedAuthors` can be liked or reposted through your site.
111
105
 
112
- A "follow me" link can become a **Follow** button for signed-in readers: mark it with the account's DID and handle. It shows "Following" once they do (a second press unfollows, as on Bluesky). The first follow asks the reader's account host for one more permission. Only your `allowedAuthors` can be followed through your site, and the account's owner (who can't follow themselves) keeps the plain link.
106
+ A "follow me" link can become a **Follow** button for signed-in readers: mark it with the account's DID and handle. It shows "Following" once they do (a second press unfollows, as on Bluesky). Only your `allowedAuthors` can be followed through your site, and the account's owner (who can't follow themselves) keeps the plain link.
113
107
 
114
108
  ```astro
115
109
  <a href="https://bsky.app/profile/you.bsky.social"
@@ -130,7 +124,7 @@ Run the dev server on `127.0.0.1` and open the site there, not at `localhost`:
130
124
  npx astro dev --host 127.0.0.1
131
125
  ```
132
126
 
133
- Locally the integration uses the protocol's development mode, so no key is needed and it works without a public address. Bluesky's approval screen will say a development app is asking; that's expected.
127
+ Locally the integration uses the protocol's development mode, so it works without a public address. Bluesky's approval screen will say a development app is asking, and sign-ins last only about a day; that's expected.
134
128
 
135
129
  ## Options
136
130
 
@@ -139,7 +133,7 @@ Locally the integration uses the protocol's development mode, so no key is neede
139
133
  | `clientName` | yes | Your site's name, given to readers' account hosts. (Bluesky's approval page shows your site's address instead, unless Bluesky has marked your site as trusted.) |
140
134
  | `allowedAuthors` | yes | DIDs of the accounts whose posts can be commented on through your site. Comments from these accounts get the Author badge. |
141
135
  | `logoUri` | | A square logo for the sign-in screen (absolute `https://` address). |
142
- | `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. |
136
+ | `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. The privacy policy is also linked under the sign-in form. |
143
137
  | `hidden` | | Comments never to show: bsky.app post or profile addresses, AT URIs, handles or DIDs. Replies you hide in the Bluesky app, and posts with Bluesky's moderation labels (spam and so on), are left out anyway. |
144
138
  | `base` | | Where the integration's routes live. Default `/atmosphere`. |
145
139
  | `locale` | | The language of the wording, such as `"fr"` or `"pt-BR"` (see Languages). |
@@ -207,17 +201,21 @@ The component fires (bubbling):
207
201
  - `atmosphere:commented` after a comment, reply or quote is posted (`detail`: `uri`, `url`, `text`, `author`, `createdAt`, `quote`, `parent`);
208
202
  - `atmosphere:reacted` after a like or repost of the post, or undoing one (`detail`: `kind`, `on`, `author`).
209
203
  - `atmosphere:deleted` after a reader deletes one of their comments (`detail`: `uri`).
204
+ - `atmosphere:hidden` after the announcement's author hides a comment (`detail`: `uri`).
210
205
 
211
206
  ## Security and privacy
212
207
 
213
- - **What your server stores:** for each signed-in reader, their DID and their OAuth tokens (which can only post, like and repost as described above, and delete posts if the reader allowed it), plus a random cookie value. Files are readable only by your site's user. Mention this in your privacy policy.
214
- - **What it refuses:** requests that change something must come from your own pages (checked by `Origin`); only your announcements and comments in their threads can be acted on; only a reader's own replies under your announcements can be deleted; comments, likes and searches are rate-limited per account, and searches by readers who aren't signed in per address and overall; the image route only passes on avatars and link pictures it has just shown.
215
- - **Moderation:** comments appear automatically. Hide one in the Bluesky app (it disappears here too), block the account, or add it to `hidden`.
216
- - Comments are plain text, as on Bluesky: links, @mentions and #hashtags become links.
208
+ - **Readers' sign-ins stay in their browsers.** The OAuth library (`@atproto/oauth-client-browser`) keeps the tokens in the browser's storage, together with a key the browser makes and won't let even the page's own code read out; the tokens only work with that key. Your server never receives them, sets no cookies and stores nothing about readers. Signing out cancels the sign-in at the reader's account host and deletes it from the browser.
209
+ - **What that does and doesn't promise.** Your server can't act for anyone, ever. While a reader has your page open, though, your page's code can use their sign-in, as with any web app they sign in to, so the code that runs matters: it's this package's open source code, and comments are only ever shown as plain text (links, @mentions and #hashtags become links; nothing is rendered as HTML). A [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) on your site adds a second line of defence.
210
+ - **What's checked before anything is sent** (by the browser code, `lib/client/account.ts`): only your announcements and the comments under them can be replied to, liked, reposted or quoted; only a reader's own replies under your announcements can be deleted; only your `allowedAuthors` can be followed; only an announcement's author can hide comments under it.
211
+ - **What your server does:** it shows the threads, looks up accounts and suggestions, finds the links and mentions in a comment before it's posted, and passes on avatars and link pictures it has just shown. None of it needs to know who a reader is. Each route that calls Bluesky is rate-limited per address, with an overall cap; requests that aren't simple reads must come from your own pages (checked by `Origin`). Readers' addresses are used only for those limits, never stored.
212
+ - **Trade-off:** account hosts trust browser sign-ins less than ones kept on a server, so they last less long (Bluesky's currently last about two weeks), and there's no way for a site to sign everyone out at once. For a reader who'd rather not trust your server, that's the point.
213
+ - **People who'd rather not be shown publicly:** Bluesky's setting "Discourage apps from showing my account to logged-out users" is respected, as your page is public. A comment from such an account shows only as "Not shown here…" (no name, picture or words reach the page), the replies under it stay, and the account is left out of the faces of who liked and reposted.
214
+ - **Moderation:** comments appear automatically. Hide one with Hide under it (signed in as the account that posted the announcement) or in the Bluesky app; it disappears here too. Show it again in the Bluesky app. You can also block the account, or add it to `hidden`.
217
215
 
218
216
  ## Limitations (for now)
219
217
 
220
- - Needs a Node server with a lasting disk (no serverless or edge hosts yet).
218
+ - Needs an on-demand (server) adapter, for the few routes above. Serverless hosts should work, as nothing is stored, but haven't been tried.
221
219
  - Rate limits are kept in memory, so they apply per server process.
222
220
  - Quoting individual comments isn't supported.
223
221
 
@@ -121,6 +121,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
121
121
  </button>
122
122
  </div>
123
123
 
124
+ {/* A sign-in from before posting was asked for: one more permission. */}
125
+ <div class="atmo__permission" data-permission hidden>
126
+ <p data-permission-text>{t("composer.needsPermission")}</p>
127
+ <p class="atmo__hint">{t("permission.approve")}</p>
128
+ <div class="atmo__row">
129
+ <button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
130
+ <button type="button" class="atmo__link-button" data-permission-sign-out>{t("composer.signOut")}</button>
131
+ </div>
132
+ </div>
133
+
124
134
  <Composer id="atmo" messages={messages} lang={lang} signOut />
125
135
  </div>
126
136
 
@@ -143,6 +153,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
143
153
  import { translate } from "../lib/translate";
144
154
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
145
155
  import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
156
+ import { handleSignInForm, me as whoIsSignedIn, send, signIn, signOut, state } from "../lib/client/account";
157
+ import type { Permission } from "../lib/permissions";
158
+ import { authorOf } from "../lib/uris";
146
159
 
147
160
 
148
161
  // The error and status codes the routes and the sign-in round trip send,
@@ -155,7 +168,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
155
168
  "signed-out": "messages.signedOut",
156
169
  "needs-permission": "messages.needsPermission",
157
170
  "not-allowed": "messages.notAllowed",
158
- "new-permissions": "messages.newPermissions",
159
171
  "too-long": "messages.tooLong",
160
172
  "too-many": "messages.tooMany",
161
173
  error: "messages.error",
@@ -177,23 +189,32 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
177
189
  const signedOut = $<HTMLElement>("[data-view='signed-out']");
178
190
  const signedIn = $<HTMLElement>("[data-view='signed-in']");
179
191
  const composer = $<HTMLFormElement>("[data-composer]");
192
+ const permissionPanel = $<HTMLElement>("[data-permission]");
180
193
  // A clean copy of the comment box, for reply boxes under comments.
181
194
  const template = composer.cloneNode(true) as HTMLFormElement;
182
195
  const loginForm = $<HTMLFormElement>("[data-login-form]");
183
196
  const status = $<HTMLElement>("[data-status]");
184
197
  let isSignedIn = false;
185
- let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
198
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
199
+ const can = (permission: Permission) => !!me.can?.includes(permission);
186
200
  // What the main box is doing: a comment on the post, or a quote post.
187
201
  let mainMode: "comment" | "quote" = "comment";
188
202
 
189
203
  const say = (message: string) => (status.textContent = message);
190
- const send = (path: string, body: unknown) =>
191
- fetch(`${base}/${path}`, {
192
- method: "POST",
193
- credentials: "same-origin",
194
- headers: { "content-type": "application/json" },
195
- body: JSON.stringify(body),
196
- });
204
+ // Sends the reader to their account host for one more permission,
205
+ // coming back to `hash` on this page (to finish what they asked).
206
+ const askPermission = (permission: Permission, hash = "#atmosphere-comments") =>
207
+ signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
208
+ // Says what the permission is for before going (the account host may
209
+ // ask the reader to sign in again first, which is puzzling unexplained),
210
+ // with Continue to approve. Delete and Hide do the same in their panels.
211
+ const explainPermission = (permission: "post" | "react") => {
212
+ const go = Object.assign(make("button", "atmo__link-button", t("comment.continue")), { type: "button" });
213
+ go.addEventListener("click", () => void askPermission(permission));
214
+ status.replaceChildren(`${t(`permission.${permission}`)} ${t("permission.approve")} `, go);
215
+ status.scrollIntoView({ block: "nearest", behavior: "smooth" });
216
+ go.focus({ preventScroll: true });
217
+ };
197
218
  // Elements this script creates get the component's marker attribute, so
198
219
  // Astro's scoped styles still reach them.
199
220
  const scoped = <T extends HTMLElement>(element: T): T => {
@@ -216,6 +237,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
216
237
  depth: number;
217
238
  likeCount: number;
218
239
  repostCount: number;
240
+ withheld?: true;
219
241
  };
220
242
  type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
221
243
  let thread: Thread | null = null;
@@ -272,7 +294,17 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
272
294
  const replyElement = (reply: Reply) => {
273
295
  const item = make("li", `atmo-comment${reply.byAuthor ? " atmo-comment--author" : ""}${reply.depth ? " atmo-comment--nested" : ""}`);
274
296
  if (reply.depth) item.style.setProperty("--depth", String(Math.min(reply.depth, 3)));
297
+ // Its author asks not to be shown to logged-out people: a placeholder,
298
+ // keeping the replies under it in place. (No AT URI, so no actions.)
299
+ if (reply.withheld) {
300
+ item.classList.add("atmo-comment--withheld");
301
+ const article = make("article");
302
+ article.append(make("p", "atmo-comment__withheld", t("comment.withheld")));
303
+ item.append(article);
304
+ return item;
305
+ }
275
306
  item.dataset.atmosphereUri = reply.uri;
307
+ item.dataset.atmosphereUrl = reply.url;
276
308
  item.dataset.atmosphereAuthor = nameOf(reply.author);
277
309
  item.dataset.atmosphereLikes = String(reply.likeCount);
278
310
  item.dataset.atmosphereReposts = String(reply.repostCount);
@@ -385,6 +417,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
385
417
  const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
386
418
 
387
419
  enhanceHandleBox(loginForm, base);
420
+ handleSignInForm(loginForm, lang);
388
421
 
389
422
  // An avatar picture, or the name's first letter if there isn't one.
390
423
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -395,8 +428,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
395
428
  img.onload = () => slot.replaceChildren(img);
396
429
  };
397
430
 
398
- const handleError = (error: string, tell: (message: string) => void = say) => {
399
- if (error === "signed-out" || error === "new-permissions") void show(false);
431
+ const handleError = (error: string, tell: (message: string) => void = say, permission?: string) => {
432
+ if (error === "signed-out") void show(false);
433
+ // The page checks before it starts, so this is rare: ask now.
434
+ if (error === "needs-permission" && (permission === "post" || permission === "react")) return explainPermission(permission);
400
435
  tell(message(error));
401
436
  };
402
437
 
@@ -453,6 +488,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
453
488
  main.textarea.focus();
454
489
  });
455
490
  $<HTMLButtonElement>("[data-quote]").addEventListener("click", () => {
491
+ if (!can("post")) return explainPermission("post");
456
492
  setMainMode("quote");
457
493
  main.textarea.focus();
458
494
  });
@@ -537,7 +573,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
537
573
  } catch (problem) {
538
574
  button.setAttribute("aria-pressed", String(!on));
539
575
  setCount(before);
540
- handleError((problem as { error?: string })?.error ?? "error");
576
+ const { error, permission } = (problem ?? {}) as { error?: string; permission?: string };
577
+ handleError(error ?? "error", say, permission);
541
578
  }
542
579
  });
543
580
  return button;
@@ -559,6 +596,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
559
596
  reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
560
597
  reply.addEventListener("click", () => {
561
598
  if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
599
+ if (!can("post")) return explainPermission("post");
562
600
  openReply(comment, author);
563
601
  });
564
602
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
@@ -583,7 +621,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
583
621
  this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
584
622
  return;
585
623
  }
586
- if (data.error === "needs-permission") return askDeletePermission(comment, panel);
624
+ // The page checks first, so this is rare: ask now.
625
+ if (data.error === "needs-permission") return void askPermission("delete", `#atmo-delete=${encodeURIComponent(uri)}`);
587
626
  panel.remove();
588
627
  handleError(data.error ?? "error");
589
628
  } catch {
@@ -592,58 +631,112 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
592
631
  }
593
632
  };
594
633
 
595
- // Deleting posts isn't part of signing in, so the first delete asks the
596
- // reader's account host for it, then carries on when they come back.
597
- const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
598
- const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
599
- const field = (name: string, value: string) =>
600
- form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
601
- field("handle", me.did!);
602
- field("permission", "delete");
603
- field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
604
- const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
605
- const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
606
- cancel.addEventListener("click", () => panel.remove());
607
- form.append(go, cancel);
608
- panel.replaceChildren(
609
- make(
610
- "span",
611
- "atmo-comment-delete__text",
612
- t("comment.deletePermission"),
613
- ),
614
- form,
615
- );
616
- go.focus();
634
+ // Hide, for the announcement's own author: hides a comment here and on
635
+ // Bluesky. Showing it again is done in the Bluesky app.
636
+ const isOwner = () => !!me.did && !!post && authorOf(post) === me.did;
637
+
638
+ const hideComment = async (comment: HTMLElement, panel: HTMLElement) => {
639
+ const uri = comment.dataset.atmosphereUri!;
640
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.hiding")));
641
+ try {
642
+ const response = await send("hide", { post: uri, root: post });
643
+ const data = (await response.json()) as { error?: string };
644
+ if (response.ok) {
645
+ comment.remove();
646
+ say(t("comment.hidden"));
647
+ this.dispatchEvent(new CustomEvent("atmosphere:hidden", { detail: { uri }, bubbles: true }));
648
+ return;
649
+ }
650
+ if (data.error === "needs-permission") return void askPermission("moderate", `#atmo-hide=${encodeURIComponent(uri)}`);
651
+ panel.remove();
652
+ handleError(data.error ?? "error");
653
+ } catch {
654
+ panel.remove();
655
+ say(message("error"));
656
+ }
617
657
  };
618
658
 
619
- const deleteButton = (comment: HTMLElement) => {
620
- const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
621
- button.setAttribute("aria-label", t("comment.deleteLabel"));
622
- button.addEventListener("click", () => {
623
- const actions = button.parentElement!;
624
- if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
625
- const panel = make("div", "atmo-comment-delete");
626
- const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
627
- const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
628
- yes.addEventListener("click", () => deleteComment(comment, panel));
629
- no.addEventListener("click", () => {
630
- panel.remove();
631
- button.focus();
632
- });
633
- panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
634
- panel.setAttribute("role", "group");
635
- panel.setAttribute("aria-label", t("comment.deleteLabel"));
659
+ // Delete and Hide each need one more permission than signing in gives.
660
+ // Readers who haven't given it yet are asked for it first; once they
661
+ // have (straight away, or back from approving), they confirm.
662
+ const STEPS = {
663
+ delete: {
664
+ permission: "delete",
665
+ action: "comment.delete",
666
+ label: "comment.deleteLabel",
667
+ confirm: "comment.deleteConfirm",
668
+ needs: "comment.deletePermission",
669
+ // "{{link}}" in the wording: the comment on Bluesky, to delete it there instead.
670
+ elsewhere: "comment.deleteElsewhere",
671
+ run: deleteComment,
672
+ },
673
+ hide: {
674
+ permission: "moderate",
675
+ action: "comment.hide",
676
+ label: "comment.hideLabel",
677
+ confirm: "comment.hideConfirm",
678
+ needs: "comment.hidePermission",
679
+ elsewhere: "",
680
+ run: hideComment,
681
+ },
682
+ } as const;
683
+
684
+ const openPanel = (comment: HTMLElement, kind: keyof typeof STEPS) => {
685
+ const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
686
+ if (!actions || actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
687
+ const step = STEPS[kind];
688
+ const panel = make("div", "atmo-comment-delete");
689
+ panel.setAttribute("role", "group");
690
+ panel.setAttribute("aria-label", t(step.label));
691
+ const button = (text: string, danger = false) =>
692
+ Object.assign(make("button", `atmo-comment-action${danger ? " atmo-comment-action--danger" : ""}`, text), { type: "button" });
693
+ const no = button(t("comment.cancel"));
694
+ no.addEventListener("click", () => {
695
+ panel.remove();
696
+ actions.querySelector<HTMLElement>(`.atmo-comment-action--${kind}`)?.focus();
697
+ });
698
+ if (can(step.permission)) {
699
+ const yes = button(t(step.action), true);
700
+ yes.addEventListener("click", () => step.run(comment, panel));
701
+ panel.append(make("span", "atmo-comment-delete__text", t(step.confirm)), yes, no);
636
702
  actions.after(panel);
637
703
  no.focus();
638
- });
704
+ } else {
705
+ const go = button(t("comment.continue"), true);
706
+ go.addEventListener("click", () =>
707
+ void askPermission(step.permission, `#atmo-${kind}=${encodeURIComponent(comment.dataset.atmosphereUri!)}`),
708
+ );
709
+ const text = make("span", "atmo-comment-delete__text");
710
+ const [before, after = ""] = t(step.needs, { link: "\u0000" }).split("\u0000");
711
+ text.append(before);
712
+ if (step.elsewhere) {
713
+ const url = comment.dataset.atmosphereUrl;
714
+ text.append(
715
+ url
716
+ ? Object.assign(make("a", "", t(step.elsewhere)), { href: url, target: "_blank", rel: "noopener noreferrer" })
717
+ : t(step.elsewhere),
718
+ after,
719
+ );
720
+ }
721
+ panel.append(text, go, no);
722
+ actions.after(panel);
723
+ go.focus();
724
+ }
725
+ };
726
+
727
+ const panelButton = (comment: HTMLElement, kind: keyof typeof STEPS) => {
728
+ const button = Object.assign(make("button", `atmo-comment-action atmo-comment-action--${kind}`, t(STEPS[kind].action)), { type: "button" });
729
+ button.setAttribute("aria-label", t(STEPS[kind].label));
730
+ button.addEventListener("click", () => openPanel(comment, kind));
639
731
  return button;
640
732
  };
641
733
 
642
734
  const addDeleteButtons = () => {
643
735
  document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
644
736
  const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
645
- if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
646
- if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
737
+ if (!actions || actions.querySelector(".atmo-comment-action--delete, .atmo-comment-action--hide")) return;
738
+ if (isSignedIn && isMine(comment)) actions.append(panelButton(comment, "delete"));
739
+ else if (isSignedIn && isOwner()) actions.append(panelButton(comment, "hide"));
647
740
  });
648
741
  };
649
742
 
@@ -653,14 +746,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
653
746
  const comments = [...document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]")];
654
747
  const uris = [post, ...comments.map((comment) => comment.dataset.atmosphereUri!)];
655
748
  try {
656
- const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
657
- const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
658
- const state = (await response.json()) as {
749
+ const response = await state(uris);
750
+ const found = (await response.json()) as {
659
751
  error?: string;
660
752
  posts?: Record<string, { liked: boolean; reposted: boolean }>;
661
753
  };
662
- if (!response.ok) return handleError(state.error ?? "error");
663
- const posts = state.posts ?? {};
754
+ if (!response.ok) return handleError(found.error ?? "error");
755
+ const posts = found.posts ?? {};
664
756
  known = { ...known, ...posts };
665
757
  setPressed("like", !!posts[post]?.liked);
666
758
  setPressed("repost", !!posts[post]?.reposted);
@@ -689,23 +781,28 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
689
781
  signedOut.hidden = signed;
690
782
  signedIn.hidden = !signed;
691
783
  if (!signed) {
692
- document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
784
+ document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-action--hide, .atmo-comment-delete").forEach((part) => part.remove());
693
785
  return closeReplies();
694
786
  }
787
+ // A sign-in without posting (from an older version, which asked for
788
+ // less): one more permission, asked for before they type.
789
+ composer.hidden = !can("post");
790
+ permissionPanel.hidden = can("post");
791
+ // Which account, as they may have more than one.
792
+ $<HTMLElement>("[data-permission-text]").textContent = t("composer.needsPermission", { handle: handle ? `@${handle}` : "" });
695
793
  addCommentActions();
794
+ addDeleteButtons();
696
795
  await loadState();
697
- // Back from approving the delete permission: finish the delete they asked for.
698
- const pending = location.hash.match(/^#atmo-delete=(.+)$/);
796
+ // Back from approving the delete or hide permission: ask them to
797
+ // confirm the delete or hide they started.
798
+ const pending = location.hash.match(/^#atmo-(delete|hide)=(.+)$/);
699
799
  if (pending) {
700
800
  history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
701
- const uri = decodeURIComponent(pending[1]);
702
- const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
703
- const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
704
- if (comment && actions && isMine(comment)) {
705
- const panel = make("div", "atmo-comment-delete");
706
- actions.after(panel);
801
+ const kind = pending[1] as keyof typeof STEPS;
802
+ const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(decodeURIComponent(pending[2]))}"]`);
803
+ if (comment && (kind === "delete" ? isMine(comment) : isOwner())) {
707
804
  comment.scrollIntoView({ block: "center" });
708
- await deleteComment(comment, panel);
805
+ openPanel(comment, kind);
709
806
  }
710
807
  }
711
808
  };
@@ -720,11 +817,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
720
817
  }
721
818
 
722
819
  this.classList.add("is-loading");
723
- fetch(`${base}/me`, { credentials: "same-origin" })
724
- .then((response) => response.json())
725
- .then((found: { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string }) => {
820
+ whoIsSignedIn()
821
+ .then((found) => {
726
822
  me = found;
727
- return show(found.signedIn, found.handle, found.displayName, found.avatar);
823
+ return found.signedIn ? show(true, found.handle, found.displayName, found.avatar) : show(false);
728
824
  })
729
825
  .catch(() => show(false))
730
826
  .finally(() => {
@@ -751,7 +847,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
751
847
  const response = await send("react", { post, kind, on });
752
848
  if (!response.ok) {
753
849
  setPressed(kind, !on);
754
- return handleError((await response.json()).error);
850
+ const { error, permission } = (await response.json()) as { error?: string; permission?: string };
851
+ return handleError(error ?? "error", say, permission);
755
852
  }
756
853
  say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
757
854
  showReaction(kind, on);
@@ -768,12 +865,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
768
865
  });
769
866
  }
770
867
 
771
- $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", async () => {
772
- await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
868
+ const signOutHere = async () => {
869
+ await signOut();
870
+ me = {};
773
871
  void show(false);
774
872
  setMainMode("comment");
775
873
  say(t("status.signedOut"));
776
- });
874
+ };
875
+ $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", signOutHere);
876
+ $<HTMLButtonElement>("[data-permission-sign-out]").addEventListener("click", signOutHere);
877
+ $<HTMLButtonElement>("[data-permission-continue]").addEventListener("click", () => void askPermission("post"));
777
878
  }
778
879
  }
779
880