astro-atmosphere-comments 0.1.0 → 0.2.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 +16 -0
- package/README.md +18 -12
- package/components/AtmosphereComments.astro +319 -68
- package/index.mjs +16 -5
- package/lib/oauth.ts +18 -7
- package/lib/reader.ts +18 -3
- package/package.json +2 -2
- package/routes/delete.ts +41 -0
- package/routes/login.ts +4 -2
- package/routes/typeahead.ts +48 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (4 November 2026)
|
|
4
|
+
|
|
5
|
+
- The sign-in form is shown straight away under each post: a handle box and a **Sign in with Atmosphere** button, both with the Atmosphere logo. The "Respond with your Atmosphere Account" button that opened it is gone.
|
|
6
|
+
- `label` is now the form's heading (default "Sign in to leave a comment, like or repost"). If you set it for the old button's small text, change or remove it.
|
|
7
|
+
- As readers type in the handle box, **matching accounts are suggested** with their avatar, name and handle, so they can see they've found the right one; picking one signs in with it. Enter still signs in with exactly what was typed.
|
|
8
|
+
- Under the sign-in form, **"Rather not sign in here? Reply on Bluesky instead →"** links to the announcement, for readers who'd rather reply in the Bluesky app (`blueskyLink={false}` hides it). If your page already says this in the `after-box` slot, remove one.
|
|
9
|
+
- Readers can **delete their own comments**: a Delete button under each of them, with a confirmation. Signing in no longer asks for permission to delete posts; the first delete asks the reader's account host for it (one more approval), then finishes. The server only ever deletes the reader's own replies under your announcements. Fires `atmosphere:deleted` (`detail`: `uri`).
|
|
10
|
+
- The site's OAuth client metadata moved to `/oauth-client-metadata.json` at the site's root (was `/atmosphere/client-metadata.json`), so Bluesky's approval page names the site by its address ("saltedlolly.com") instead of showing a file address. **Readers already signed in will need to sign in once more**, because their sign-in belonged to the old address. If your site already serves a file at that address, the two will clash.
|
|
11
|
+
- The handle box keeps `name="handle"` (the name most atproto apps use, so browsers can suggest handles typed elsewhere) and asks password managers to leave it alone: it isn't a login for your site, so they offered the wrong one. This follows the community's emerging convention and may change (see AGENTS.md).
|
|
12
|
+
- `--atmo-danger` sets the colour of the confirming Delete button.
|
|
13
|
+
- Pressing Reply, Like or Repost on a comment while signed out moves to the form and puts the cursor in the handle box.
|
|
14
|
+
|
|
15
|
+
## 0.1.1 (3 November 2026)
|
|
16
|
+
|
|
17
|
+
- The credit link now goes to a permanent address, saltedlolly.com/atmosphere-comments-for-astro/.
|
|
18
|
+
|
|
3
19
|
## 0.1.0 (3 November 2026)
|
|
4
20
|
|
|
5
21
|
First release, as used on [saltedlolly.com](https://saltedlolly.com).
|
package/README.md
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Bluesky comments for your Astro site, without the reader leaving the page. (On npm: [`astro-atmosphere-comments`](https://www.npmjs.com/package/astro-atmosphere-comments).)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Under each post, readers type their Bluesky handle (or that of any other [Atmosphere account](https://atmosphereaccount.com)) into the **Sign in with Atmosphere** form, which suggests matching accounts with their avatars as they type, and can then:
|
|
6
6
|
|
|
7
7
|
- **comment** on your post, and **reply** to other comments;
|
|
8
8
|
- **like** and **repost** your post, or any comment under it;
|
|
9
9
|
- **quote** your post on their own profile, with a Bluesky-style preview;
|
|
10
|
-
- **@mention** people, with suggestions as they type
|
|
10
|
+
- **@mention** people, with suggestions as they type;
|
|
11
|
+
- **delete** their own comments.
|
|
11
12
|
|
|
12
13
|
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.
|
|
13
14
|
|
|
@@ -18,11 +19,13 @@ See it on [saltedlolly.com](https://saltedlolly.com/2005/07/13/summer-jam-party-
|
|
|
18
19
|
## How it works
|
|
19
20
|
|
|
20
21
|
1. You announce each post on Bluesky (a post linking to it). That post's thread is the comment section.
|
|
21
|
-
2. A reader signs in with **AT Protocol OAuth, straight to their own account host** (Bluesky's, for most people). There is no third-party login service in between, and your site never sees a password.
|
|
22
|
-
3. Your site asks only for what commenting needs: to create and delete
|
|
22
|
+
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. Readers can revoke it all at any time in their Bluesky settings.
|
|
23
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.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
Readers who'd rather not sign in can use the **Reply on Bluesky instead** link under the form.
|
|
27
|
+
|
|
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.
|
|
26
29
|
|
|
27
30
|
## Requirements
|
|
28
31
|
|
|
@@ -67,7 +70,7 @@ To find an account's DID: `https://bsky.social/xrpc/com.atproto.identity.resolve
|
|
|
67
70
|
npx astro-atmosphere-comments keygen
|
|
68
71
|
```
|
|
69
72
|
|
|
70
|
-
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`. Set the same variable on your server, keep `.env` out of Git, and keep a backup: **a new key signs every reader out.**
|
|
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.**
|
|
71
74
|
|
|
72
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.
|
|
73
76
|
|
|
@@ -97,7 +100,7 @@ Locally the integration uses the protocol's development mode, so no key is neede
|
|
|
97
100
|
|
|
98
101
|
| Option | Required | What it does |
|
|
99
102
|
| --- | --- | --- |
|
|
100
|
-
| `clientName` | yes | Your site's name
|
|
103
|
+
| `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.) |
|
|
101
104
|
| `allowedAuthors` | yes | DIDs of the accounts whose posts can be commented on through your site. Comments from these accounts get the Author badge. |
|
|
102
105
|
| `logoUri` | | A square logo for the sign-in screen (absolute `https://` address). |
|
|
103
106
|
| `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. |
|
|
@@ -109,13 +112,14 @@ Locally the integration uses the protocol's development mode, so no key is neede
|
|
|
109
112
|
| Prop | What it does |
|
|
110
113
|
| --- | --- |
|
|
111
114
|
| `post` | The announcement: bsky.app address or AT URI. Optional with Standard.site (above). |
|
|
112
|
-
| `label` | The
|
|
115
|
+
| `label` | The sign-in form's heading. Default "Sign in to leave a comment, like or repost". |
|
|
113
116
|
| `authorLabel` | The badge on your own comments. Default "Author". |
|
|
117
|
+
| `blueskyLink` | "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form, linking to the announcement (with `u-syndication`, so microformats readers learn it's the post's copy on Bluesky). Default `true`; `blueskyLink={false}` hides it. |
|
|
114
118
|
| `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
|
|
115
119
|
|
|
116
120
|
**Slots:** `after-box` goes between the comment box and the comments (a note about other ways to respond, say); the default slot goes after the comments (other responses, older comments).
|
|
117
121
|
|
|
118
|
-
**Layout, top to bottom:** likes and reposts, the
|
|
122
|
+
**Layout, top to bottom:** likes and reposts, the sign-in form and the "Reply on Bluesky instead" line (or, signed in, Like, Repost, Quote and the comment box), your `after-box` content, the comments, your default slot content.
|
|
119
123
|
|
|
120
124
|
## Styling
|
|
121
125
|
|
|
@@ -130,8 +134,9 @@ It has a plain default look and takes your text colour. Colours come from CSS cu
|
|
|
130
134
|
--atmo-border: #ddd;
|
|
131
135
|
--atmo-field-bg: #f6f6f6; /* text boxes */
|
|
132
136
|
--atmo-card-bg: #fafafa; /* comments */
|
|
133
|
-
--atmo-menu-bg: #fff; /* @mention suggestions */
|
|
137
|
+
--atmo-menu-bg: #fff; /* account and @mention suggestions */
|
|
134
138
|
--atmo-ring: #fff; /* the outline round faces */
|
|
139
|
+
--atmo-danger: #c0392b; /* Delete, when confirming */
|
|
135
140
|
--atmo-radius: 10px;
|
|
136
141
|
}
|
|
137
142
|
```
|
|
@@ -144,11 +149,12 @@ The component fires (bubbling):
|
|
|
144
149
|
|
|
145
150
|
- `atmosphere:commented` after a comment, reply or quote is posted (`detail`: `uri`, `url`, `text`, `author`, `createdAt`, `quote`, `parent`);
|
|
146
151
|
- `atmosphere:reacted` after a like or repost of the post, or undoing one (`detail`: `kind`, `on`, `author`).
|
|
152
|
+
- `atmosphere:deleted` after a reader deletes one of their comments (`detail`: `uri`).
|
|
147
153
|
|
|
148
154
|
## Security and privacy
|
|
149
155
|
|
|
150
|
-
- **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), plus a random cookie value. Files are readable only by your site's user. Mention this in your privacy policy.
|
|
151
|
-
- **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; comments, likes and searches are rate-limited per account; the image route only passes on avatars and link pictures it has just shown.
|
|
156
|
+
- **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.
|
|
157
|
+
- **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.
|
|
152
158
|
- **Moderation:** comments appear automatically. Hide one in the Bluesky app (it disappears here too), block the account, or add it to `hidden`.
|
|
153
159
|
- Comments are plain text, as on Bluesky: links, @mentions and #hashtags become links.
|
|
154
160
|
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
// The comment box under a post:
|
|
3
|
-
// once signed in, Like, Repost and Quote buttons and a box to write a
|
|
2
|
+
// The comment box under a post: a form to sign in with an Atmosphere account
|
|
3
|
+
// (Bluesky or any other), then, once signed in, Like, Repost and Quote buttons and a box to write a
|
|
4
4
|
// comment (a reply to the post's announcement, or to one of its comments).
|
|
5
5
|
//
|
|
6
6
|
// Props:
|
|
7
7
|
// post the announcement's AT URI (at://did/app.bsky.feed.post/rkey).
|
|
8
|
-
// label the
|
|
9
|
-
// (default "
|
|
8
|
+
// label the sign-in form's heading
|
|
9
|
+
// (default "Sign in to leave a comment, like or repost").
|
|
10
|
+
// blueskyLink "Rather not sign in here? Reply on Bluesky instead →" under the
|
|
11
|
+
// sign-in form (default true; false hides it).
|
|
10
12
|
// credit a small "Powered by Atmosphere Comments for Astro" link under the
|
|
11
13
|
// comments (default true; false hides it).
|
|
12
14
|
//
|
|
13
15
|
// Quote shows a preview of the reader's post with the announcement quoted
|
|
14
16
|
// inside, avatars and link-card picture included (passed on by this site).
|
|
15
17
|
//
|
|
16
|
-
// Typing @ and the start of a handle suggests accounts to mention
|
|
18
|
+
// Typing @ and the start of a handle suggests accounts to mention, and typing
|
|
19
|
+
// in the sign-in box suggests matching accounts to sign in with.
|
|
17
20
|
//
|
|
18
21
|
// Comments: under every element on the page with a `data-atmosphere-uri`
|
|
19
22
|
// attribute (a comment's AT URI) the component adds Reply, Like and Repost.
|
|
@@ -38,15 +41,32 @@ interface Props {
|
|
|
38
41
|
* is used to find it.
|
|
39
42
|
*/
|
|
40
43
|
post?: string;
|
|
44
|
+
/** The sign-in form's heading. */
|
|
41
45
|
label?: string;
|
|
42
46
|
/** The badge on comments by the site's own accounts. */
|
|
43
47
|
authorLabel?: string;
|
|
44
48
|
/** A small "Powered by Atmosphere Comments for Astro" link under the comments. Default true. */
|
|
45
49
|
credit?: boolean;
|
|
50
|
+
/** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
|
|
51
|
+
blueskyLink?: boolean;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
const {
|
|
49
|
-
|
|
54
|
+
const {
|
|
55
|
+
post = "",
|
|
56
|
+
label = "Sign in to leave a comment, like or repost",
|
|
57
|
+
authorLabel = "Author",
|
|
58
|
+
credit = true,
|
|
59
|
+
blueskyLink = true,
|
|
60
|
+
} = Astro.props;
|
|
61
|
+
// The announcement on bsky.app, when the page is given it (otherwise the
|
|
62
|
+
// script fills it in once the thread has loaded).
|
|
63
|
+
const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
|
|
64
|
+
const blueskyUrl = atUri
|
|
65
|
+
? `https://bsky.app/profile/${atUri[1]}/post/${atUri[2]}`
|
|
66
|
+
: /^https:\/\/bsky\.app\/profile\/[^/]+\/post\/[^/]+$/.test(post)
|
|
67
|
+
? post
|
|
68
|
+
: "";
|
|
69
|
+
const CREDIT_URL = "https://saltedlolly.com/atmosphere-comments-for-astro/";
|
|
50
70
|
const { base } = settings;
|
|
51
71
|
const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
52
72
|
---
|
|
@@ -55,30 +75,33 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
55
75
|
<div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
|
|
56
76
|
|
|
57
77
|
<div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
|
|
58
|
-
<button type="button" class="atmo__badge-button" data-open-login aria-expanded="false" aria-controls="atmo-login" hidden>
|
|
59
|
-
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
60
|
-
<span class="atmo__badge-text">
|
|
61
|
-
<span class="atmo__badge-small">{label}</span>
|
|
62
|
-
<span class="atmo__badge-big">Atmosphere Account</span>
|
|
63
|
-
</span>
|
|
64
|
-
</button>
|
|
65
78
|
<form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
|
|
66
|
-
<label for="atmo-handle">
|
|
79
|
+
<label for="atmo-handle">{label}</label>
|
|
67
80
|
<p class="atmo__hint">Any <a href="https://atmosphereaccount.com">Atmosphere account</a> works, including <a href="https://bsky.app">Bluesky</a>: one account for a whole network of open apps.</p>
|
|
68
81
|
<div class="atmo__row">
|
|
69
|
-
<
|
|
82
|
+
<span class="atmo__handle">
|
|
83
|
+
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
84
|
+
<input
|
|
70
85
|
id="atmo-handle"
|
|
71
86
|
name="handle"
|
|
72
87
|
type="text"
|
|
73
88
|
inputmode="url"
|
|
74
|
-
|
|
89
|
+
data-1p-ignore
|
|
90
|
+
data-lpignore="true"
|
|
91
|
+
data-bwignore
|
|
92
|
+
data-form-type="other"
|
|
75
93
|
autocapitalize="none"
|
|
76
94
|
spellcheck="false"
|
|
77
95
|
placeholder="alice.bsky.social"
|
|
78
96
|
required
|
|
79
|
-
|
|
97
|
+
/>
|
|
98
|
+
<ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label="Matching accounts" data-handle-suggestions hidden></ul>
|
|
99
|
+
</span>
|
|
80
100
|
<input type="hidden" name="returnTo" value={returnTo} />
|
|
81
|
-
<button type="submit" class="atmo__button">
|
|
101
|
+
<button type="submit" class="atmo__button atmo__button--sign-in">
|
|
102
|
+
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
103
|
+
Sign in with Atmosphere
|
|
104
|
+
</button>
|
|
82
105
|
</div>
|
|
83
106
|
<p class="atmo__hint">
|
|
84
107
|
You'll approve this on your account's own site (Bluesky's, for most people); your password
|
|
@@ -86,6 +109,14 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
86
109
|
No account yet? <a href="https://atmosphereaccount.com">Create a free Atmosphere account</a>.
|
|
87
110
|
</p>
|
|
88
111
|
</form>
|
|
112
|
+
{blueskyLink && (
|
|
113
|
+
<p class="atmo__elsewhere" data-elsewhere hidden={!blueskyUrl}>
|
|
114
|
+
Rather not sign in here?{" "}
|
|
115
|
+
{/* u-syndication: microformats readers learn this is the post's copy on Bluesky. */}
|
|
116
|
+
<a class="atmo__elsewhere-link u-syndication" href={blueskyUrl || undefined} data-elsewhere-link>Reply on Bluesky instead →</a>{" "}
|
|
117
|
+
It shows up here just the same, as do likes and reposts.
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
89
120
|
</div>
|
|
90
121
|
|
|
91
122
|
<div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
|
|
@@ -185,6 +216,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
185
216
|
"account-not-found": "We couldn't find that account. Check the handle and try again.",
|
|
186
217
|
"invalid-handle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
|
|
187
218
|
"signed-out": "You've been signed out. Please sign in again.",
|
|
219
|
+
"needs-permission": "To delete comments here, this site needs one more permission from your account.",
|
|
220
|
+
"not-allowed": "That can't be done here.",
|
|
188
221
|
"new-permissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
|
|
189
222
|
"too-long": `Posts can be up to ${MAX} characters.`,
|
|
190
223
|
"too-many": "You're going quickly. Please wait a minute and try again.",
|
|
@@ -206,7 +239,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
206
239
|
const composer = $<HTMLFormElement>("[data-composer]");
|
|
207
240
|
// A clean copy of the comment box, for reply boxes under comments.
|
|
208
241
|
const template = composer.cloneNode(true) as HTMLFormElement;
|
|
209
|
-
const loginButton = $<HTMLButtonElement>("[data-open-login]");
|
|
210
242
|
const loginForm = $<HTMLFormElement>("[data-login-form]");
|
|
211
243
|
const status = $<HTMLElement>("[data-status]");
|
|
212
244
|
let isSignedIn = false;
|
|
@@ -245,7 +277,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
245
277
|
likeCount: number;
|
|
246
278
|
repostCount: number;
|
|
247
279
|
};
|
|
248
|
-
type Thread = { uri: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
|
|
280
|
+
type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
|
|
249
281
|
let thread: Thread | null = null;
|
|
250
282
|
const reactionsBox = $<HTMLElement>("[data-reactions]");
|
|
251
283
|
const repliesList = $<HTMLOListElement>("[data-replies]");
|
|
@@ -359,6 +391,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
359
391
|
}
|
|
360
392
|
thread = data.thread;
|
|
361
393
|
post = thread.uri;
|
|
394
|
+
const elsewhere = this.querySelector<HTMLElement>("[data-elsewhere]");
|
|
395
|
+
if (elsewhere && thread.url) {
|
|
396
|
+
elsewhere.querySelector("a")!.href = thread.url;
|
|
397
|
+
elsewhere.hidden = false;
|
|
398
|
+
}
|
|
362
399
|
renderReactions();
|
|
363
400
|
repliesList.replaceChildren(...thread.replies.map(replyElement));
|
|
364
401
|
} catch {
|
|
@@ -404,19 +441,100 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
404
441
|
renderReactions();
|
|
405
442
|
};
|
|
406
443
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
loginForm.querySelector("
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
444
|
+
const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
|
|
445
|
+
|
|
446
|
+
// Matching accounts under the handle box as the reader types, with their
|
|
447
|
+
// avatars, so they can see they've found the right one. Picking one signs
|
|
448
|
+
// in with it. Without JavaScript the box works as a plain text box.
|
|
449
|
+
{
|
|
450
|
+
const input = loginForm.querySelector<HTMLInputElement>("#atmo-handle")!;
|
|
451
|
+
const list = loginForm.querySelector<HTMLUListElement>("[data-handle-suggestions]")!;
|
|
452
|
+
let found: Suggestion[] = [];
|
|
453
|
+
let active = -1;
|
|
454
|
+
let latest = "";
|
|
455
|
+
let timer: number | undefined;
|
|
456
|
+
input.setAttribute("role", "combobox");
|
|
457
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
458
|
+
input.setAttribute("aria-controls", list.id);
|
|
459
|
+
input.setAttribute("aria-expanded", "false");
|
|
460
|
+
|
|
461
|
+
const close = () => {
|
|
462
|
+
list.hidden = true;
|
|
463
|
+
list.replaceChildren();
|
|
464
|
+
found = [];
|
|
465
|
+
active = -1;
|
|
466
|
+
input.setAttribute("aria-expanded", "false");
|
|
467
|
+
input.removeAttribute("aria-activedescendant");
|
|
468
|
+
};
|
|
469
|
+
const highlight = (index: number) => {
|
|
470
|
+
active = index;
|
|
471
|
+
list.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
|
|
472
|
+
if (index >= 0) input.setAttribute("aria-activedescendant", `${list.id}-option-${index}`);
|
|
473
|
+
else input.removeAttribute("aria-activedescendant");
|
|
474
|
+
};
|
|
475
|
+
const choose = (index: number) => {
|
|
476
|
+
const option = found[index];
|
|
477
|
+
if (!option) return;
|
|
478
|
+
input.value = option.handle;
|
|
479
|
+
close();
|
|
480
|
+
loginForm.requestSubmit();
|
|
481
|
+
};
|
|
482
|
+
const show = (actors: Suggestion[]) => {
|
|
483
|
+
found = actors.slice(0, 5);
|
|
484
|
+
if (!found.length || document.activeElement !== input) return close();
|
|
485
|
+
list.replaceChildren(
|
|
486
|
+
...found.map((option, index) => {
|
|
487
|
+
const item = make("li");
|
|
488
|
+
item.id = `${list.id}-option-${index}`;
|
|
489
|
+
item.setAttribute("role", "option");
|
|
490
|
+
const avatar = make("span", "atmo__avatar atmo__avatar--small");
|
|
491
|
+
avatar.setAttribute("aria-hidden", "true");
|
|
492
|
+
setAvatar(avatar, option.displayName || option.handle, option.avatar);
|
|
493
|
+
item.append(avatar, make("strong", "", option.displayName || option.handle), make("span", "", `@${option.handle}`));
|
|
494
|
+
// mousedown keeps the focus in the handle box.
|
|
495
|
+
item.addEventListener("mousedown", (event) => event.preventDefault());
|
|
496
|
+
item.addEventListener("click", () => choose(index));
|
|
497
|
+
return item;
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
list.hidden = false;
|
|
501
|
+
input.setAttribute("aria-expanded", "true");
|
|
502
|
+
// Nothing is picked until the reader chooses, so Enter still signs in
|
|
503
|
+
// with exactly what they typed.
|
|
504
|
+
highlight(-1);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
input.addEventListener("input", () => {
|
|
508
|
+
window.clearTimeout(timer);
|
|
509
|
+
const query = input.value.trim().replace(/^@/, "");
|
|
510
|
+
latest = query;
|
|
511
|
+
if (query.length < 2 || query.startsWith("did:") || /\s/.test(query)) return close();
|
|
512
|
+
timer = window.setTimeout(async () => {
|
|
513
|
+
try {
|
|
514
|
+
const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
|
|
515
|
+
const data = (await response.json()) as { actors?: Suggestion[] };
|
|
516
|
+
if (query === latest) show(data.actors ?? []);
|
|
517
|
+
} catch {
|
|
518
|
+
close();
|
|
519
|
+
}
|
|
520
|
+
}, 250);
|
|
521
|
+
});
|
|
522
|
+
input.addEventListener("keydown", (event) => {
|
|
523
|
+
if (list.hidden) return;
|
|
524
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
525
|
+
event.preventDefault();
|
|
526
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
527
|
+
highlight(active < 0 && step < 0 ? found.length - 1 : (active + step + found.length) % found.length);
|
|
528
|
+
} else if (event.key === "Enter" && active >= 0) {
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
choose(active);
|
|
531
|
+
} else if (event.key === "Escape") {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
close();
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
input.addEventListener("blur", () => window.setTimeout(close, 150));
|
|
537
|
+
}
|
|
420
538
|
|
|
421
539
|
// An avatar picture, or the name's first letter if there isn't one.
|
|
422
540
|
const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
|
|
@@ -765,9 +883,89 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
765
883
|
actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
|
|
766
884
|
(comment.querySelector("article") ?? comment).append(actions);
|
|
767
885
|
});
|
|
886
|
+
addDeleteButtons();
|
|
768
887
|
return added;
|
|
769
888
|
};
|
|
770
889
|
|
|
890
|
+
// Delete, under the signed-in reader's own comments only.
|
|
891
|
+
const isMine = (comment: HTMLElement) => !!me.did && !!comment.dataset.atmosphereUri?.startsWith(`at://${me.did}/`);
|
|
892
|
+
|
|
893
|
+
const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
|
|
894
|
+
const uri = comment.dataset.atmosphereUri!;
|
|
895
|
+
panel.replaceChildren(make("span", "atmo-comment-delete__text", "Deleting…"));
|
|
896
|
+
try {
|
|
897
|
+
const response = await send("delete", { post: uri });
|
|
898
|
+
const data = (await response.json()) as { error?: string };
|
|
899
|
+
if (response.ok) {
|
|
900
|
+
comment.remove();
|
|
901
|
+
say("Your comment was deleted.");
|
|
902
|
+
this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (data.error === "needs-permission") return askDeletePermission(comment, panel);
|
|
906
|
+
panel.remove();
|
|
907
|
+
handleError(data.error ?? "error");
|
|
908
|
+
} catch {
|
|
909
|
+
panel.remove();
|
|
910
|
+
say(MESSAGES.error);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// Deleting posts isn't part of signing in, so the first delete asks the
|
|
915
|
+
// reader's account host for it, then carries on when they come back.
|
|
916
|
+
const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
|
|
917
|
+
const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
|
|
918
|
+
const field = (name: string, value: string) =>
|
|
919
|
+
form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
|
|
920
|
+
field("handle", me.did!);
|
|
921
|
+
field("permission", "delete");
|
|
922
|
+
field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
|
|
923
|
+
const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Continue to approve"), { type: "submit" });
|
|
924
|
+
const cancel = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
|
|
925
|
+
cancel.addEventListener("click", () => panel.remove());
|
|
926
|
+
form.append(go, cancel);
|
|
927
|
+
panel.replaceChildren(
|
|
928
|
+
make(
|
|
929
|
+
"span",
|
|
930
|
+
"atmo-comment-delete__text",
|
|
931
|
+
"To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
|
|
932
|
+
),
|
|
933
|
+
form,
|
|
934
|
+
);
|
|
935
|
+
go.focus();
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
const deleteButton = (comment: HTMLElement) => {
|
|
939
|
+
const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", "Delete"), { type: "button" });
|
|
940
|
+
button.setAttribute("aria-label", "Delete your comment");
|
|
941
|
+
button.addEventListener("click", () => {
|
|
942
|
+
const actions = button.parentElement!;
|
|
943
|
+
if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
|
|
944
|
+
const panel = make("div", "atmo-comment-delete");
|
|
945
|
+
const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Delete"), { type: "button" });
|
|
946
|
+
const no = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
|
|
947
|
+
yes.addEventListener("click", () => deleteComment(comment, panel));
|
|
948
|
+
no.addEventListener("click", () => {
|
|
949
|
+
panel.remove();
|
|
950
|
+
button.focus();
|
|
951
|
+
});
|
|
952
|
+
panel.append(make("span", "atmo-comment-delete__text", "Delete this comment from Bluesky?"), yes, no);
|
|
953
|
+
panel.setAttribute("role", "group");
|
|
954
|
+
panel.setAttribute("aria-label", "Delete your comment");
|
|
955
|
+
actions.after(panel);
|
|
956
|
+
no.focus();
|
|
957
|
+
});
|
|
958
|
+
return button;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const addDeleteButtons = () => {
|
|
962
|
+
document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
|
|
963
|
+
const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
|
|
964
|
+
if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
|
|
965
|
+
if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
|
|
966
|
+
});
|
|
967
|
+
};
|
|
968
|
+
|
|
771
969
|
// Which of the post and its comments the reader has already liked or reposted.
|
|
772
970
|
const loadState = async () => {
|
|
773
971
|
if (!post) return;
|
|
@@ -809,9 +1007,26 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
809
1007
|
isSignedIn = signed;
|
|
810
1008
|
signedOut.hidden = signed;
|
|
811
1009
|
signedIn.hidden = !signed;
|
|
812
|
-
if (!signed)
|
|
1010
|
+
if (!signed) {
|
|
1011
|
+
document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
|
|
1012
|
+
return closeReplies();
|
|
1013
|
+
}
|
|
813
1014
|
addCommentActions();
|
|
814
1015
|
await loadState();
|
|
1016
|
+
// Back from approving the delete permission: finish the delete they asked for.
|
|
1017
|
+
const pending = location.hash.match(/^#atmo-delete=(.+)$/);
|
|
1018
|
+
if (pending) {
|
|
1019
|
+
history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
|
|
1020
|
+
const uri = decodeURIComponent(pending[1]);
|
|
1021
|
+
const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
|
|
1022
|
+
const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
|
|
1023
|
+
if (comment && actions && isMine(comment)) {
|
|
1024
|
+
const panel = make("div", "atmo-comment-delete");
|
|
1025
|
+
actions.after(panel);
|
|
1026
|
+
comment.scrollIntoView({ block: "center" });
|
|
1027
|
+
await deleteComment(comment, panel);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
815
1030
|
};
|
|
816
1031
|
|
|
817
1032
|
// A message from the sign-in round trip (?atmosphere=…), then tidy the address.
|
|
@@ -900,50 +1115,49 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
900
1115
|
display: none !important;
|
|
901
1116
|
}
|
|
902
1117
|
|
|
903
|
-
/* The
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
border-radius: 12px;
|
|
912
|
-
background: none;
|
|
913
|
-
color: var(--atmo-text, currentColor);
|
|
914
|
-
font-family: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
915
|
-
text-align: left;
|
|
916
|
-
cursor: pointer;
|
|
1118
|
+
/* The Atmosphere Account logo inside the handle box, on the left, and at
|
|
1119
|
+
the start of the sign-in button. It is single-colour, so it is drawn in
|
|
1120
|
+
the muted text colour in the box and the button's text colour on it. */
|
|
1121
|
+
.atmo__handle {
|
|
1122
|
+
position: relative;
|
|
1123
|
+
display: flex;
|
|
1124
|
+
flex: 1 1 220px;
|
|
1125
|
+
min-width: 0;
|
|
917
1126
|
}
|
|
918
1127
|
|
|
919
|
-
.
|
|
920
|
-
.
|
|
921
|
-
|
|
922
|
-
color: var(--atmo-accent, #1185fe);
|
|
1128
|
+
/* Matching accounts sit under the handle box, as wide as it. */
|
|
1129
|
+
.atmo__handle .atmo__suggestions {
|
|
1130
|
+
width: 100%;
|
|
923
1131
|
}
|
|
924
1132
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1133
|
+
.atmo__handle .atmo__logo {
|
|
1134
|
+
position: absolute;
|
|
1135
|
+
top: 50%;
|
|
1136
|
+
left: 11px;
|
|
1137
|
+
width: 20px;
|
|
1138
|
+
height: 20px;
|
|
1139
|
+
transform: translateY(-50%);
|
|
1140
|
+
background-color: var(--atmo-muted, currentColor);
|
|
931
1141
|
mask: var(--atmo-logo) center / contain no-repeat;
|
|
1142
|
+
pointer-events: none;
|
|
932
1143
|
}
|
|
933
1144
|
|
|
934
|
-
.
|
|
935
|
-
|
|
936
|
-
line-height: 1.15;
|
|
1145
|
+
.atmo .atmo__handle input[type="text"] {
|
|
1146
|
+
padding-left: 40px;
|
|
937
1147
|
}
|
|
938
1148
|
|
|
939
|
-
.
|
|
940
|
-
|
|
1149
|
+
.atmo__button--sign-in {
|
|
1150
|
+
display: inline-flex;
|
|
1151
|
+
align-items: center;
|
|
1152
|
+
gap: 8px;
|
|
941
1153
|
}
|
|
942
1154
|
|
|
943
|
-
.
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1155
|
+
.atmo__button--sign-in .atmo__logo {
|
|
1156
|
+
flex: none;
|
|
1157
|
+
width: 20px;
|
|
1158
|
+
height: 20px;
|
|
1159
|
+
background-color: currentColor;
|
|
1160
|
+
mask: var(--atmo-logo) center / contain no-repeat;
|
|
947
1161
|
}
|
|
948
1162
|
|
|
949
1163
|
/* The comment box when it sits under a comment being replied to. */
|
|
@@ -955,6 +1169,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
955
1169
|
margin-top: 10px;
|
|
956
1170
|
}
|
|
957
1171
|
|
|
1172
|
+
.atmo__elsewhere {
|
|
1173
|
+
max-width: 560px;
|
|
1174
|
+
margin: 14px 0 0;
|
|
1175
|
+
font-size: 0.9em;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
.atmo__elsewhere-link {
|
|
1179
|
+
font-weight: 700;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
958
1182
|
.atmo__login,
|
|
959
1183
|
.atmo__composer {
|
|
960
1184
|
display: grid;
|
|
@@ -1459,6 +1683,24 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1459
1683
|
margin-top: 8px;
|
|
1460
1684
|
}
|
|
1461
1685
|
|
|
1686
|
+
:global(.atmo-comment-delete) {
|
|
1687
|
+
display: flex;
|
|
1688
|
+
flex-wrap: wrap;
|
|
1689
|
+
align-items: center;
|
|
1690
|
+
gap: 6px 12px;
|
|
1691
|
+
margin-top: 8px;
|
|
1692
|
+
font-size: 0.9em;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
:global(.atmo-comment-delete form) {
|
|
1696
|
+
display: contents;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
:global(.atmo-comment-delete__text) {
|
|
1700
|
+
color: var(--atmo-muted, inherit);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
|
|
1462
1704
|
:global(.atmo-comment-action) {
|
|
1463
1705
|
display: inline-flex;
|
|
1464
1706
|
align-items: center;
|
|
@@ -1473,6 +1715,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1473
1715
|
cursor: pointer;
|
|
1474
1716
|
}
|
|
1475
1717
|
|
|
1718
|
+
:global(.atmo-comment-action--delete) {
|
|
1719
|
+
margin-left: auto;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
:global(.atmo-comment-action--danger) {
|
|
1723
|
+
color: var(--atmo-danger, #c0392b);
|
|
1724
|
+
font-weight: 700;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1476
1727
|
:global(.atmo-comment-action:hover),
|
|
1477
1728
|
:global(.atmo-comment-action:focus-visible),
|
|
1478
1729
|
:global(.atmo-comment-action[aria-pressed="true"]) {
|
package/index.mjs
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Sign-in uses AT Protocol OAuth straight to the reader's own account host:
|
|
8
8
|
// no third-party login service is involved. This site only ever holds a
|
|
9
|
-
// grant to create and delete
|
|
10
|
-
//
|
|
9
|
+
// grant to create posts and to create and delete likes and reposts, plus, if
|
|
10
|
+
// a reader allows it when they first delete a comment, to delete posts (see
|
|
11
|
+
// SCOPE and DELETE_SCOPE in lib/oauth.ts).
|
|
11
12
|
//
|
|
12
|
-
// It adds
|
|
13
|
-
//
|
|
13
|
+
// It adds /oauth-client-metadata.json at the site's root (what account hosts
|
|
14
|
+
// read to know the site), and these routes under `base` (default /atmosphere):
|
|
15
|
+
// jwks.json the public half of the site's signing key
|
|
14
16
|
// login (POST) starts sign-in with the reader's handle
|
|
15
17
|
// callback where the reader's host sends them back
|
|
16
18
|
// logout (POST) signs out and revokes the site's grant
|
|
@@ -30,7 +32,6 @@ import { fileURLToPath } from "node:url";
|
|
|
30
32
|
const VIRTUAL_ID = "virtual:atmosphere-comments/settings";
|
|
31
33
|
|
|
32
34
|
const ROUTES = {
|
|
33
|
-
"client-metadata.json": "client-metadata.json.ts",
|
|
34
35
|
"jwks.json": "jwks.json.ts",
|
|
35
36
|
login: "login.ts",
|
|
36
37
|
callback: "callback.ts",
|
|
@@ -39,6 +40,7 @@ const ROUTES = {
|
|
|
39
40
|
comment: "comment.ts",
|
|
40
41
|
state: "state.ts",
|
|
41
42
|
react: "react.ts",
|
|
43
|
+
delete: "delete.ts",
|
|
42
44
|
post: "post.ts",
|
|
43
45
|
media: "media.ts",
|
|
44
46
|
typeahead: "typeahead.ts",
|
|
@@ -80,6 +82,15 @@ export default function atmosphereComments(options) {
|
|
|
80
82
|
hidden: options.hidden ?? [],
|
|
81
83
|
};
|
|
82
84
|
|
|
85
|
+
// The client metadata lives at the address account hosts treat as the
|
|
86
|
+
// convention, so Bluesky's approval page names the app by the site's
|
|
87
|
+
// address alone (saltedlolly.com) rather than the full file address.
|
|
88
|
+
injectRoute({
|
|
89
|
+
pattern: "/oauth-client-metadata.json",
|
|
90
|
+
entrypoint: fileURLToPath(new URL("./routes/client-metadata.json.ts", import.meta.url)),
|
|
91
|
+
prerender: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
83
94
|
for (const [path, file] of Object.entries(ROUTES)) {
|
|
84
95
|
injectRoute({
|
|
85
96
|
pattern: `${base}/${path}`,
|
package/lib/oauth.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// permission to post comments on their behalf.
|
|
3
3
|
//
|
|
4
4
|
// - Live, the site is a "confidential client": account hosts read its
|
|
5
|
-
// client-metadata.json, and it proves who it is with a private key
|
|
5
|
+
// /oauth-client-metadata.json, and it proves who it is with a private key
|
|
6
6
|
// (ATMOSPHERE_PRIVATE_JWK, server-only). Sign-ins can then last for months.
|
|
7
7
|
// - In `astro dev`, it uses the protocol's local-testing client (no key, no
|
|
8
8
|
// public address needed). Open the site at http://127.0.0.1:<port>, not
|
|
@@ -19,31 +19,42 @@ import { getSecret } from "astro:env/server";
|
|
|
19
19
|
import settings from "./settings";
|
|
20
20
|
import { fileStore } from "./store";
|
|
21
21
|
|
|
22
|
-
// Only what commenting needs: create (and delete again)
|
|
23
|
-
// reposts, and read posts as the reader sees them (to know whether they've
|
|
22
|
+
// Only what commenting needs: create posts, create (and delete again) likes
|
|
23
|
+
// and reposts, and read posts as the reader sees them (to know whether they've
|
|
24
24
|
// already liked or reposted), through Bluesky's AppView.
|
|
25
25
|
export const SCOPE = [
|
|
26
26
|
"atproto",
|
|
27
|
-
"repo:app.bsky.feed.post?action=create
|
|
27
|
+
"repo:app.bsky.feed.post?action=create",
|
|
28
28
|
"repo:app.bsky.feed.like?action=create&action=delete",
|
|
29
29
|
"repo:app.bsky.feed.repost?action=create&action=delete",
|
|
30
30
|
"rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview",
|
|
31
31
|
].join(" ");
|
|
32
32
|
|
|
33
|
+
// Deleting posts is asked for separately, only when a reader first deletes a
|
|
34
|
+
// comment here, so most readers never grant it. (A permission can only name a
|
|
35
|
+
// kind of record, not particular posts: the delete route itself makes sure it
|
|
36
|
+
// is only used on the reader's own comments under the site's posts.)
|
|
37
|
+
export const DELETE_SCOPE = "repo:app.bsky.feed.post?action=delete";
|
|
38
|
+
|
|
39
|
+
// Everything the site may ask for, which its client metadata has to list.
|
|
40
|
+
const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE}`;
|
|
41
|
+
|
|
33
42
|
const KEY_ID = "atmosphere-comments-1";
|
|
34
43
|
|
|
35
44
|
export const callbackPath = `${settings.base}/callback`;
|
|
36
45
|
|
|
37
46
|
export function clientMetadata() {
|
|
38
47
|
return {
|
|
39
|
-
|
|
48
|
+
// At the site's root, the address account hosts treat as the convention:
|
|
49
|
+
// Bluesky's approval page then shows just the site's address.
|
|
50
|
+
client_id: `${settings.site}/oauth-client-metadata.json`,
|
|
40
51
|
client_name: settings.clientName,
|
|
41
52
|
client_uri: settings.site,
|
|
42
53
|
...(settings.logoUri && { logo_uri: settings.logoUri }),
|
|
43
54
|
...(settings.policyUri && { policy_uri: settings.policyUri }),
|
|
44
55
|
...(settings.tosUri && { tos_uri: settings.tosUri }),
|
|
45
56
|
redirect_uris: [`${settings.site}${callbackPath}`] as [string],
|
|
46
|
-
scope:
|
|
57
|
+
scope: ALL_SCOPES,
|
|
47
58
|
grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
|
|
48
59
|
response_types: ["code"] as ["code"],
|
|
49
60
|
application_type: "web" as const,
|
|
@@ -84,7 +95,7 @@ export function oauthClient(origin: string): Promise<NodeOAuthClient> {
|
|
|
84
95
|
return new NodeOAuthClient({
|
|
85
96
|
...common,
|
|
86
97
|
clientMetadata: buildAtprotoLoopbackClientMetadata({
|
|
87
|
-
scope:
|
|
98
|
+
scope: ALL_SCOPES,
|
|
88
99
|
redirect_uris: [`${local.origin}${callbackPath}`],
|
|
89
100
|
}),
|
|
90
101
|
});
|
package/lib/reader.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { oauthClient, SCOPE } from "./oauth";
|
|
|
9
9
|
import settings from "./settings";
|
|
10
10
|
import { signedInDid, signOut } from "./visitor";
|
|
11
11
|
|
|
12
|
-
export type Reader = { did: string; session: OAuthSession };
|
|
12
|
+
export type Reader = { did: string; session: OAuthSession; scopes: string[] };
|
|
13
13
|
|
|
14
14
|
const APPVIEW_PROXY = "did:web:api.bsky.app#bsky_appview";
|
|
15
15
|
const POST_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._~-]+)$/;
|
|
@@ -29,12 +29,13 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
|
|
|
29
29
|
const session = await (await oauthClient(url.origin)).restore(did);
|
|
30
30
|
// Compared by permission name ("repo:app.bsky.feed.like"), since a host
|
|
31
31
|
// may write the parameters after it differently.
|
|
32
|
-
const
|
|
32
|
+
const scopes = (await session.getTokenInfo(false)).scope.split(" ");
|
|
33
|
+
const granted = scopes.map((part) => part.split("?")[0]);
|
|
33
34
|
if (!SCOPE.split(" ").every((part) => granted.includes(part.split("?")[0]))) {
|
|
34
35
|
await signOut(cookies);
|
|
35
36
|
return json({ error: "new-permissions" }, 401);
|
|
36
37
|
}
|
|
37
|
-
return { did, session };
|
|
38
|
+
return { did, session, scopes };
|
|
38
39
|
} catch {
|
|
39
40
|
// The grant has expired or been revoked: the reader needs to sign in again.
|
|
40
41
|
await signOut(cookies);
|
|
@@ -42,6 +43,20 @@ export async function reader(request: Request, url: URL, cookies: AstroCookies):
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whether the reader's grant allows `action` on records of `collection`: a
|
|
48
|
+
* repo permission for that collection (or all of them) listing the action,
|
|
49
|
+
* or listing no actions, which means all of them.
|
|
50
|
+
*/
|
|
51
|
+
export function allows(reader: Reader, collection: string, action: string): boolean {
|
|
52
|
+
return reader.scopes.some((scope) => {
|
|
53
|
+
const [name, query = ""] = scope.split("?");
|
|
54
|
+
if (name !== `repo:${collection}` && name !== "repo:*") return false;
|
|
55
|
+
const actions = new URLSearchParams(query).getAll("action");
|
|
56
|
+
return !actions.length || actions.includes(action);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
/** True for a post by one of the site's own accounts (an announcement). */
|
|
46
61
|
export function isSitePost(uri: unknown): uri is string {
|
|
47
62
|
const match = typeof uri === "string" ? POST_URI.exec(uri) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-atmosphere-comments",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Atmosphere Comments for Astro: Bluesky comments, likes, reposts and quote posts for Astro sites; readers sign in with their Atmosphere (AT Protocol) account and respond without leaving the page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"types": "./index.d.ts",
|
|
33
33
|
"bin": {
|
|
34
|
-
"astro-atmosphere-comments": "
|
|
34
|
+
"astro-atmosphere-comments": "bin/cli.mjs"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.mjs",
|
package/routes/delete.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Deletes one of the signed-in reader's own comments: a post of theirs that
|
|
2
|
+
// is a reply somewhere under one of the site's announcements. Nothing else,
|
|
3
|
+
// even though the permission behind it covers all of the reader's posts.
|
|
4
|
+
// Body: { post }. Readers who haven't allowed deleting yet get
|
|
5
|
+
// "needs-permission", and the page offers to ask their account host for it.
|
|
6
|
+
import type { APIRoute } from "astro";
|
|
7
|
+
import { json } from "../lib/http";
|
|
8
|
+
import { allows, deleteRecord, isPostUri, isSitePost, postsAsReader, rateLimit, reader } from "../lib/reader";
|
|
9
|
+
import { forgetThread } from "../lib/thread";
|
|
10
|
+
|
|
11
|
+
const tooMany = rateLimit(10, 60);
|
|
12
|
+
|
|
13
|
+
export const POST: APIRoute = async ({ request, url, cookies }) => {
|
|
14
|
+
const signedIn = await reader(request, url, cookies);
|
|
15
|
+
if (signedIn instanceof Response) return signedIn;
|
|
16
|
+
|
|
17
|
+
let body: { post?: unknown };
|
|
18
|
+
try {
|
|
19
|
+
body = await request.json();
|
|
20
|
+
} catch {
|
|
21
|
+
return json({ error: "bad-request" }, 400);
|
|
22
|
+
}
|
|
23
|
+
const { post } = body;
|
|
24
|
+
if (!isPostUri(post) || !post.startsWith(`at://${signedIn.did}/`)) return json({ error: "not-allowed" }, 400);
|
|
25
|
+
if (!allows(signedIn, "app.bsky.feed.post", "delete")) return json({ error: "needs-permission" }, 403);
|
|
26
|
+
if (tooMany(signedIn.did)) return json({ error: "too-many" }, 429);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const [view] = await postsAsReader(signedIn.session, [post]);
|
|
30
|
+
if (!view) return json({ error: "not-found" }, 404);
|
|
31
|
+
// Only a reply under one of the site's announcements, never a post of its own.
|
|
32
|
+
const root = view.record?.reply?.root?.uri;
|
|
33
|
+
if (!isSitePost(root)) return json({ error: "not-allowed" }, 400);
|
|
34
|
+
await deleteRecord(signedIn, view.uri);
|
|
35
|
+
forgetThread(root);
|
|
36
|
+
return json({ deleted: view.uri });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn("[atmosphere] delete failed:", error instanceof Error ? error.message : error);
|
|
39
|
+
return json({ error: "post-failed" }, 502);
|
|
40
|
+
}
|
|
41
|
+
};
|
package/routes/login.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// approve this site's request to post comments for them.
|
|
4
4
|
import type { APIRoute } from "astro";
|
|
5
5
|
import { safeReturnPath, withStatus } from "../lib/http";
|
|
6
|
-
import { oauthClient, SCOPE } from "../lib/oauth";
|
|
6
|
+
import { DELETE_SCOPE, oauthClient, SCOPE } from "../lib/oauth";
|
|
7
7
|
|
|
8
8
|
export const POST: APIRoute = async ({ request, url, redirect }) => {
|
|
9
9
|
const form = await request.formData();
|
|
@@ -19,7 +19,9 @@ export const POST: APIRoute = async ({ request, url, redirect }) => {
|
|
|
19
19
|
try {
|
|
20
20
|
const client = await oauthClient(url.origin);
|
|
21
21
|
const authorizeUrl = await client.authorize(handle, {
|
|
22
|
-
|
|
22
|
+
// The delete route sends readers back here (with permission=delete) when
|
|
23
|
+
// they first delete a comment and haven't allowed that yet.
|
|
24
|
+
scope: form.get("permission") === "delete" ? `${SCOPE} ${DELETE_SCOPE}` : SCOPE,
|
|
23
25
|
state: JSON.stringify({ returnTo }),
|
|
24
26
|
});
|
|
25
27
|
return redirect(authorizeUrl.href, 303);
|
package/routes/typeahead.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Account suggestions as the reader types (?q=start of a handle or name), from
|
|
2
|
+
// Bluesky's public typeahead search: for @mentions in the comment box, and for
|
|
3
|
+
// the handle box when signing in, so readers can see they've found the right
|
|
4
|
+
// account. Avatars come through this site. Signed-in readers are limited per
|
|
5
|
+
// account; everyone else per address, with an overall cap as well (behind a
|
|
6
|
+
// proxy every visitor may share one address), so the site can't be used to
|
|
7
|
+
// flood Bluesky's search. Answers are remembered for a minute.
|
|
4
8
|
import type { APIRoute } from "astro";
|
|
5
9
|
import { json } from "../lib/http";
|
|
6
10
|
import { rateLimit } from "../lib/reader";
|
|
@@ -8,24 +12,50 @@ import settings from "../lib/settings";
|
|
|
8
12
|
import { rememberAvatar } from "../lib/avatars";
|
|
9
13
|
import { signedInDid } from "../lib/visitor";
|
|
10
14
|
|
|
11
|
-
const
|
|
15
|
+
const perReader = rateLimit(60, 1200);
|
|
16
|
+
const perAddress = rateLimit(30, 300);
|
|
17
|
+
const signedOutOverall = rateLimit(300, 6000);
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
type Actor = { did: string; handle: string; displayName?: string; avatar?: string };
|
|
20
|
+
const recent = new Map<string, { actors: Actor[]; at: number }>();
|
|
21
|
+
const REMEMBER_MS = 60 * 1000;
|
|
22
|
+
const MAX_REMEMBERED = 500;
|
|
23
|
+
|
|
24
|
+
async function search(q: string): Promise<Actor[]> {
|
|
25
|
+
const key = q.toLowerCase();
|
|
26
|
+
const known = recent.get(key);
|
|
27
|
+
if (known && Date.now() - known.at < REMEMBER_MS) return known.actors;
|
|
28
|
+
const response = await fetch(
|
|
29
|
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
|
|
30
|
+
{ signal: AbortSignal.timeout(4000) },
|
|
31
|
+
);
|
|
32
|
+
if (!response.ok) return [];
|
|
33
|
+
const actors = ((await response.json()) as { actors?: Actor[] }).actors ?? [];
|
|
34
|
+
if (recent.size >= MAX_REMEMBERED) recent.delete(recent.keys().next().value!);
|
|
35
|
+
recent.set(key, { actors, at: Date.now() });
|
|
36
|
+
return actors;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const GET: APIRoute = async ({ url, cookies, clientAddress }) => {
|
|
40
|
+
const q = (url.searchParams.get("q") ?? "").trim().replace(/^@/, "");
|
|
14
41
|
const did = await signedInDid(cookies);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
42
|
+
// One letter is enough for an @mention; signing in needs two.
|
|
43
|
+
if (!q || (!did && q.length < 2) || q.length > 64 || /[\s<>"]/.test(q)) return json({ actors: [] });
|
|
44
|
+
|
|
45
|
+
if (did) {
|
|
46
|
+
if (perReader(did)) return json({ error: "too-many" }, 429);
|
|
47
|
+
} else {
|
|
48
|
+
let address = "unknown";
|
|
49
|
+
try {
|
|
50
|
+
address = clientAddress;
|
|
51
|
+
} catch {
|
|
52
|
+
// Some adapters can't tell; the overall cap still applies.
|
|
53
|
+
}
|
|
54
|
+
if (perAddress(address) || signedOutOverall("all")) return json({ error: "too-many" }, 429);
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
try {
|
|
20
|
-
const
|
|
21
|
-
`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`,
|
|
22
|
-
{ signal: AbortSignal.timeout(4000) },
|
|
23
|
-
);
|
|
24
|
-
if (!response.ok) return json({ actors: [] });
|
|
25
|
-
const data = (await response.json()) as {
|
|
26
|
-
actors?: { did: string; handle: string; displayName?: string; avatar?: string }[];
|
|
27
|
-
};
|
|
28
|
-
const actors = (data.actors ?? []).map((actor) => {
|
|
58
|
+
const actors = (await search(q)).map((actor) => {
|
|
29
59
|
if (actor.avatar) rememberAvatar(actor.did, actor.avatar);
|
|
30
60
|
return {
|
|
31
61
|
handle: actor.handle,
|