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.
- package/CHANGELOG.md +21 -0
- package/README.md +21 -23
- package/components/AtmosphereComments.astro +179 -78
- package/components/AtmosphereReactions.astro +86 -51
- package/components/SignInForm.astro +14 -3
- package/components/atmosphere.css +21 -0
- package/index.mjs +42 -32
- package/lib/client/account.ts +553 -0
- package/lib/client/composer.ts +7 -10
- package/lib/http.ts +28 -0
- package/lib/permissions.ts +108 -0
- package/lib/public-settings.ts +18 -0
- package/lib/thread.ts +36 -6
- package/lib/uris.ts +31 -0
- package/locales/de.json +20 -4
- package/locales/en.json +20 -4
- package/locales/es.json +20 -4
- package/locales/fr.json +20 -4
- package/locales/it.json +20 -4
- package/locales/ja.json +20 -4
- package/locales/nl.json +20 -4
- package/locales/pt.json +20 -4
- package/package.json +2 -7
- package/routes/callback.astro +42 -0
- package/routes/client-metadata.json.ts +3 -2
- package/routes/following.ts +28 -0
- package/routes/identity.ts +69 -0
- package/routes/media.ts +7 -10
- package/routes/post.ts +1 -1
- package/routes/profile.ts +20 -0
- package/routes/richtext.ts +30 -0
- package/routes/thread.ts +23 -3
- package/routes/typeahead.ts +7 -23
- package/virtual.d.ts +5 -0
- package/bin/cli.mjs +0 -31
- package/lib/oauth.ts +0 -110
- package/lib/reader.ts +0 -130
- package/lib/store.ts +0 -46
- package/lib/visitor.ts +0 -42
- package/routes/callback.ts +0 -30
- package/routes/comment.ts +0 -97
- package/routes/delete.ts +0 -41
- package/routes/follow.ts +0 -71
- package/routes/jwks.json.ts +0 -13
- package/routes/login.ts +0 -58
- package/routes/logout.ts +0 -20
- package/routes/me.ts +0 -12
- package/routes/react.ts +0 -48
- 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.
|
|
24
|
-
4. Your
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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).
|
|
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
|
|
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
|
-
- **
|
|
214
|
-
- **What
|
|
215
|
-
- **
|
|
216
|
-
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
596
|
-
//
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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(
|
|
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
|
|
657
|
-
const
|
|
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(
|
|
663
|
-
const 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:
|
|
698
|
-
|
|
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
|
|
702
|
-
const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
.then((
|
|
725
|
-
.then((found: { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string }) => {
|
|
820
|
+
whoIsSignedIn()
|
|
821
|
+
.then((found) => {
|
|
726
822
|
me = found;
|
|
727
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
772
|
-
await
|
|
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
|
|