astro-atmosphere-comments 0.5.0 → 0.6.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (7 November 2026)
4
+
5
+ **Buttons for everyone, and Standard.site support for readers.** The buttons and sign-in change applies to every site; Subscribe and recommending are optional.
6
+
7
+ - **Signed-out readers see the same buttons:** Like, Repost, Quote and a "Write a comment…" box show under every post. Pressing one opens a sign-in overlay that says what it's for, and what they pressed is finished once they're back. The sign-in form and "Reply on Bluesky instead" moved into that overlay, with the detail on how signing in works behind a "How does signing in work?" toggle. New keys `signIn.more`, `comment.signIn`. In `AtmosphereReactions`, Reply and Quote now open it too, rather than going to Bluesky (⌘-click still does). Sites that style `.atmo__signed-out` should check their rules: it's gone. `signIn.heading` and `signInFirst.…` wording changed, and the `label` prop now sets the overlay's heading when commenting.
8
+
9
+ - **`standardSite` option:** `{ publication: "at://did:…/site.standard.publication/…" }`. The package still doesn't publish Standard.site records; it works with any tool that does.
10
+ - **Subscribe:** a button under each post, with Like and Repost (`.atmo-subscribe`), writes a `site.standard.graph.subscription` record to the reader's own account. Anyone not signed in gets the sign-in form and is subscribed once they're back. Only your publication can be subscribed to. Anywhere else, with `AtmosphereReactions` in your layout, a `<button data-atmosphere-subscribe hidden>` becomes Subscribe / ✓ Subscribed.
11
+ - **Menu beside Subscribe:** a line on what subscribing does, Copy link, RSS feed (with the option's `feed`), View on Bluesky and View on Standard Reader.
12
+ - **Like also recommends:** with the option set, Like on a post whose page has `<link rel="site.standard.document">` also writes a `site.standard.graph.recommend` record, and un-liking removes it. Likes on comments and on the posts `AtmosphereReactions` shows stay Bluesky likes. Recommend counts aren't shown yet.
13
+ - **Signing in shows it's started:** the Sign in button is disabled with a spinner from the moment a reader presses it, presses Enter or picks a suggested account, until the browser leaves for their account host. New class `.atmo__spinner`.
14
+ - **Quote preview closer to Bluesky:** it shows the quoted post's age and the card's divider with the domain, and leaves out empty text.
15
+ - **One approval:** whichever button a reader presses, signing in asks for everything at once. Standard.site's permission (`include:site.standard.authSocial`) is part of the first sign-in, with no second trip to the account host.
16
+
17
+ ### Upgrading from 0.5
18
+
19
+ - **If you set `standardSite`, readers already signed in are signed out once** and sign in again, so everything is approved together. Without the option, sign-ins carry on as they were.
20
+ - New wording keys (`subscribe.…`, `signInFirst.likePost`, `signInFirst.repostPost`, `signInFirst.quotePost`, `comment.prompt`), in English until they're translated.
21
+
3
22
  ## 0.5.0 (4 November 2026)
4
23
 
5
24
  **Readers' sign-ins now stay in their own browsers.** This changes how the package works, so read "Upgrading" below.
package/README.md CHANGED
@@ -23,9 +23,9 @@ See it on [saltedlolly.com](https://saltedlolly.com/2005/07/13/summer-jam-party-
23
23
  1. You announce each post on Bluesky (a post linking to it). That post's thread is the comment section.
24
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.
25
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.
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; with the `standardSite` option, to subscribe and recommend). 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.
27
27
 
28
- Readers who'd rather not sign in can use the **Reply on Bluesky instead** link under the form.
28
+ Everyone sees Like, Repost, Quote and a box to write a comment. Pressing any of them signed out opens an overlay to sign in, and what they pressed is finished once they're back. Readers who'd rather not sign in can use the **Reply on Bluesky instead** link in the overlay.
29
29
 
30
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.
31
31
 
@@ -110,6 +110,16 @@ A "follow me" link can become a **Follow** button for signed-in readers: mark it
110
110
  data-atmosphere-follow="did:plc:…" data-atmosphere-follow-handle="you.bsky.social">Follow me on Bluesky</a>
111
111
  ```
112
112
 
113
+ If you publish [Standard.site](https://standard.site) records, set the integration's `standardSite` option. Readers can then **subscribe** to your publication from your site, and read new posts in apps such as Leaflet or Standard Reader. `AtmosphereComments` shows a Subscribe button under each post, with Like and Repost. To put a button anywhere else (with `AtmosphereReactions` in your layout), mark it as below; it stays hidden without the option or JavaScript. Anyone not signed in is offered the sign-in form, and subscribed once they're back; a second press unsubscribes.
114
+
115
+ ```astro
116
+ <button type="button" data-atmosphere-subscribe hidden>
117
+ <span data-atmosphere-subscribe-label>Subscribe</span>
118
+ </button>
119
+ ```
120
+
121
+ Without a `data-atmosphere-subscribe-label` element the button keeps your own content, and you show the state with `[aria-pressed="true"]` in your CSS.
122
+
113
123
  So readers can see which account they're using, `AtmosphereReactions` also fills any element marked `data-atmosphere-account` (in your header, say) with the signed-in reader's avatar. Clicking it opens a small menu: "Signed in as @handle" and Sign out. When nobody's signed in it stays empty, so give it `hidden`:
114
124
 
115
125
  ```astro
@@ -134,6 +144,7 @@ Locally the integration uses the protocol's development mode, so it works withou
134
144
  | `allowedAuthors` | yes | DIDs of the accounts whose posts can be commented on through your site. Comments from these accounts get the Author badge. |
135
145
  | `logoUri` | | A square logo for the sign-in screen (absolute `https://` address). |
136
146
  | `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. The privacy policy is also linked under the sign-in form. |
147
+ | `standardSite` | | `{ publication: "at://did:…/site.standard.publication/…", feed: "/feed/" }`, your [Standard.site](https://standard.site) publication and, optionally, your feed for the Subscribe menu (Copy link, RSS feed, View on Bluesky, View on Standard Reader). Turns on the Subscribe button (above), and makes Like under a post also recommend the post's record, when its page has `<link rel="site.standard.document">` (the button's tooltip says so); un-liking removes the recommend. Likes on comments stay Bluesky likes. Adds Standard.site's permission to the one approval. This package doesn't publish the records: use your own script or a publishing tool. |
137
148
  | `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. |
138
149
  | `base` | | Where the integration's routes live. Default `/atmosphere`. |
139
150
  | `locale` | | The language of the wording, such as `"fr"` or `"pt-BR"` (see Languages). |
@@ -144,16 +155,16 @@ Locally the integration uses the protocol's development mode, so it works withou
144
155
  | Prop | What it does |
145
156
  | --- | --- |
146
157
  | `post` | The announcement: bsky.app address or AT URI. Optional with Standard.site (above). |
147
- | `label` | The sign-in form's heading. Default "Sign in to leave a comment, like or repost". |
158
+ | `label` | The sign-in overlay's heading when commenting. Default "Sign in to leave a comment". |
148
159
  | `authorLabel` | The badge on your own comments. Default "Author". |
149
- | `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. |
160
+ | `blueskyLink` | "Rather not sign in here? Reply on Bluesky instead →" in the sign-in overlay, 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. |
150
161
  | `locale`, `messages` | The same as the integration's options, for this one component. |
151
162
  | `locale`, `messages` (also on `AtmosphereReactions`) | See Languages. |
152
163
  | `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
153
164
 
154
165
  **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).
155
166
 
156
- **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.
167
+ **Layout, top to bottom:** likes and reposts, Like, Repost, Quote and the comment box, your `after-box` content, the comments, your default slot content.
157
168
 
158
169
  ## Languages
159
170
 
@@ -207,7 +218,7 @@ The component fires (bubbling):
207
218
 
208
219
  - **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
220
  - **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.
221
+ - **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 `standardSite` publication can be subscribed to, and only documents in its account recommended; only your `allowedAuthors` can be followed; only an announcement's author can hide comments under it.
211
222
  - **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
223
  - **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
224
  - **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.
@@ -1,14 +1,14 @@
1
1
  ---
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
- // comment (a reply to the post's announcement, or to one of its comments).
2
+ // Under a post: Like, Repost and Quote buttons and a box to write a comment (a
3
+ // reply to the post's announcement, or to one of its comments). Signed out,
4
+ // they open an overlay to sign in with an Atmosphere account (Bluesky or any
5
+ // other), and what the reader pressed is finished once they're back.
5
6
  //
6
7
  // Props:
7
8
  // post the announcement's AT URI (at://did/app.bsky.feed.post/rkey).
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).
9
+ // label the sign-in overlay's heading when commenting (default "Sign in to leave a comment").
10
+ // blueskyLink "Rather not sign in here? Reply on Bluesky instead →" in the
11
+ // sign-in overlay (default true; false hides it).
12
12
  // credit a small "Powered by Atmosphere Comments for Astro" link under the
13
13
  // comments (default true; false hides it).
14
14
  //
@@ -31,12 +31,17 @@
31
31
  // text, author, createdAt, quote, parent }), and after a like or repost (or
32
32
  // undoing one) `atmosphere:reacted` (detail: { kind, on, author }), so a site
33
33
  // can show it straight away.
34
+ //
35
+ // With the integration's `standardSite` option, Like on the post also
36
+ // recommends the page's Standard.site record (<link rel="site.standard.document">),
37
+ // and un-liking removes the recommend. Likes on comments stay Bluesky likes.
34
38
  import settings from "../lib/settings";
35
39
  import SignInForm from "./SignInForm.astro";
36
40
  import Composer from "./Composer.astro";
37
41
  import "./atmosphere.css";
38
42
  import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
39
43
  import { escapeHtml } from "../lib/translate";
44
+ import logo from "../assets/union.svg?url";
40
45
 
41
46
  interface Props {
42
47
  /**
@@ -45,13 +50,13 @@ interface Props {
45
50
  * is used to find it.
46
51
  */
47
52
  post?: string;
48
- /** The sign-in form's heading. */
53
+ /** The sign-in overlay's heading when commenting. */
49
54
  label?: string;
50
55
  /** The badge on comments by the site's own accounts. */
51
56
  authorLabel?: string;
52
57
  /** A small "Powered by Atmosphere Comments for Astro" link under the comments. Default true. */
53
58
  credit?: boolean;
54
- /** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
59
+ /** "Rather not sign in here? Reply on Bluesky instead →" in the sign-in overlay. Default true. */
55
60
  blueskyLink?: boolean;
56
61
  /** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
57
62
  locale?: string;
@@ -90,22 +95,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
90
95
  <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
91
96
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
92
97
 
93
- <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
94
- <SignInForm id="atmo" returnTo={returnTo} messages={messages} lang={lang} />
95
- {blueskyLink && (
96
- <p
97
- class="atmo__elsewhere"
98
- data-elsewhere
99
- hidden={!blueskyUrl}
100
- set:html={th("elsewhere.text", {
101
- // u-syndication: microformats readers learn this is the post's copy on Bluesky.
102
- link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
103
- })}
104
- />
105
- )}
106
- </div>
107
-
108
- <div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
98
+ {/* For everyone: signed out, these open the sign-in overlay below, and what was pressed is finished on coming back. */}
99
+ <div class="atmo__signed-in" data-view="main" data-pagefind-ignore hidden>
109
100
  <div class="atmo__actions" role="group" aria-label={t("actions.label")}>
110
101
  <button type="button" class="atmo__action" data-react="like" aria-pressed="false">
111
102
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" /></svg>
@@ -119,6 +110,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
119
110
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
120
111
  <span>{t("actions.quote")}</span>
121
112
  </button>
113
+ {/* With the `standardSite` option. */}
114
+ {settings.standardSite && (
115
+ <div class="atmo-subscribe" data-subscribe-panel data-pagefind-ignore hidden>
116
+ <button type="button" class="atmo__action atmo-subscribe__button" data-atmosphere-subscribe hidden>
117
+ <span class="atmo-subscribe__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
118
+ <span data-atmosphere-subscribe-label>{t("subscribe.subscribe")}</span>
119
+ </button>
120
+ <button type="button" class="atmo__action atmo-subscribe__more" aria-label={t("subscribe.more")} title={t("subscribe.more")} aria-expanded="false" data-subscribe-more>
121
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
122
+ </button>
123
+ <div class="atmo-subscribe__menu" data-subscribe-menu hidden>
124
+ <p
125
+ class="atmo-subscribe__note"
126
+ set:html={th("subscribe.panelText", {
127
+ leaflet: link("https://leaflet.pub", "Leaflet"),
128
+ standardReader: link("https://standard-reader.app", "Standard Reader"),
129
+ })}
130
+ />
131
+ <button type="button" data-subscribe-copy={settings.site} data-copied={t("subscribe.copied")}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></svg><span data-copy-label>{t("subscribe.copyLink")}</span></button>
132
+ {settings.standardSite.feed && <a href={settings.standardSite.feed}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 11a8 8 0 0 1 8 8M5 5a14 14 0 0 1 14 14" /><circle cx="6" cy="18" r="1" /></svg><span>{t("subscribe.feed")}</span></a>}
133
+ <a href={blueskyUrl || undefined} hidden={!blueskyUrl} data-subscribe-bluesky target="_blank" rel="noopener"><svg class="atmo-subscribe__filled" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" /></svg><span>{t("subscribe.viewBluesky")}</span></a>
134
+ <a href={`https://standard-reader.app/p/${settings.standardSite.publication.split("/")[2]}/${settings.standardSite.publication.split("/")[4]}`} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{t("subscribe.standardReader")}</span></a>
135
+ </div>
136
+ </div>
137
+ )}
122
138
  </div>
123
139
 
124
140
  {/* A sign-in from before posting was asked for: one more permission. */}
@@ -131,9 +147,35 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
131
147
  </div>
132
148
  </div>
133
149
 
150
+ {/* Signed out: looks like the comment box, and opens the sign-in overlay. */}
151
+ <div class="atmo__prompt-box" data-comment-prompt hidden>
152
+ <button type="button" class="atmo__prompt" data-comment-prompt-open>{t("comment.prompt")}</button>
153
+ <div class="atmo__row atmo__row--end">
154
+ <button type="button" class="atmo__link-button" data-comment-prompt-open>{t("comment.signIn")}</button>
155
+ <button type="button" class="atmo__button" disabled>{t("composer.postComment")}</button>
156
+ </div>
157
+ </div>
158
+
134
159
  <Composer id="atmo" messages={messages} lang={lang} signOut />
135
160
  </div>
136
161
 
162
+ <dialog class="atmo atmo-signin" aria-label={t("signIn.heading")} data-signin-dialog data-pagefind-ignore>
163
+ <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
164
+ <SignInForm id="atmo" returnTo={returnTo} messages={messages} lang={lang} />
165
+ {blueskyLink && (
166
+ <p
167
+ class="atmo__elsewhere"
168
+ data-elsewhere
169
+ hidden={!blueskyUrl}
170
+ set:html={th("elsewhere.text", {
171
+ // u-syndication: microformats readers learn this is the post's copy on Bluesky.
172
+ link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
173
+ })}
174
+ />
175
+ )}
176
+ <p class="atmo-signin__status" role="status" data-dialog-status></p>
177
+ </dialog>
178
+
137
179
  <p class="atmo__status" data-status role="status" data-pagefind-ignore></p>
138
180
 
139
181
  {/* Anything the site wants between the comment box and the comments. */}
@@ -154,6 +196,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
154
196
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
155
197
  import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
156
198
  import { handleSignInForm, me as whoIsSignedIn, send, signIn, signOut, state } from "../lib/client/account";
199
+ import { addSubscribeButtons, finishPendingSubscribe, forgetSubscribed, loadSubscribed } from "../lib/client/subscribe";
157
200
  import type { Permission } from "../lib/permissions";
158
201
  import { authorOf } from "../lib/uris";
159
202
 
@@ -186,8 +229,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
186
229
  // Standard.site record is turned into one when the thread loads.
187
230
  let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
188
231
  const $ = <T extends Element>(selector: string) => this.querySelector<T>(selector)!;
189
- const signedOut = $<HTMLElement>("[data-view='signed-out']");
190
- const signedIn = $<HTMLElement>("[data-view='signed-in']");
232
+ const commentPrompt = $<HTMLElement>("[data-comment-prompt]");
191
233
  const composer = $<HTMLFormElement>("[data-composer]");
192
234
  const permissionPanel = $<HTMLElement>("[data-permission]");
193
235
  // A clean copy of the comment box, for reply boxes under comments.
@@ -342,7 +384,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
342
384
  // Nothing to respond to: hide the box and likes, but keep whatever the
343
385
  // site put in the slots.
344
386
  const deactivate = () => {
345
- for (const part of this.querySelectorAll<HTMLElement>("[data-view], [data-reactions], [data-replies], [data-credit]")) {
387
+ for (const part of this.querySelectorAll<HTMLElement>("[data-view], [data-reactions], [data-replies], [data-credit], [data-subscribe-panel]")) {
346
388
  part.hidden = true;
347
389
  }
348
390
  this.classList.add("is-inactive");
@@ -364,6 +406,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
364
406
  }
365
407
  thread = data.thread;
366
408
  post = thread.uri;
409
+ const viewOnBluesky = this.querySelector<HTMLAnchorElement>("[data-subscribe-bluesky]");
410
+ if (viewOnBluesky && thread.url) {
411
+ viewOnBluesky.href = thread.url;
412
+ viewOnBluesky.hidden = false;
413
+ }
367
414
  const elsewhere = this.querySelector<HTMLElement>("[data-elsewhere]");
368
415
  if (elsewhere && thread.url) {
369
416
  elsewhere.querySelector("a")!.href = thread.url;
@@ -414,7 +461,36 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
414
461
  renderReactions();
415
462
  };
416
463
 
417
- const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
464
+ // The sign-in overlay: for whatever a signed-out reader pressed. They come
465
+ // back to `hash` on this page, and it's finished then (resumePending).
466
+ const signInDialog = $<HTMLDialogElement>("[data-signin-dialog]");
467
+ const dialogHeading = loginForm.querySelector("label")!;
468
+ const dialogStatus = $<HTMLElement>("[data-dialog-status]");
469
+ const openSignIn = (heading: string, hash: string, problem = "") => {
470
+ dialogHeading.textContent = heading;
471
+ signInDialog.setAttribute("aria-label", heading);
472
+ loginForm.querySelector<HTMLInputElement>("[data-return-to]")!.value = `${location.pathname}${location.search}${hash}`;
473
+ dialogStatus.textContent = problem;
474
+ if (!signInDialog.open) signInDialog.showModal();
475
+ loginForm.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
476
+ };
477
+ signInDialog.querySelector("[data-close]")!.addEventListener("click", () => signInDialog.close());
478
+ // A click on the dimmed page around the overlay closes it.
479
+ signInDialog.addEventListener("click", (event) => {
480
+ if (event.target === signInDialog) signInDialog.close();
481
+ });
482
+ const SIGN_IN_HEADINGS: Record<string, string> = {
483
+ like: "signInFirst.likePost",
484
+ repost: "signInFirst.repostPost",
485
+ quote: "signInFirst.quotePost",
486
+ comment: "signIn.heading",
487
+ reply: "signInFirst.reply",
488
+ "comment-like": "signInFirst.like",
489
+ "comment-repost": "signInFirst.repost",
490
+ };
491
+ const askToSignIn = (kind: string, uri?: string, problem?: string) =>
492
+ openSignIn(t(SIGN_IN_HEADINGS[kind]), `#atmo-do=${kind}${uri ? `:${encodeURIComponent(uri)}` : ""}`, problem);
493
+ commentPrompt.querySelectorAll("[data-comment-prompt-open]").forEach((opener) => opener.addEventListener("click", () => askToSignIn("comment")));
418
494
 
419
495
  enhanceHandleBox(loginForm, base);
420
496
  handleSignInForm(loginForm, lang);
@@ -488,6 +564,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
488
564
  main.textarea.focus();
489
565
  });
490
566
  $<HTMLButtonElement>("[data-quote]").addEventListener("click", () => {
567
+ if (!isSignedIn) return askToSignIn("quote");
491
568
  if (!can("post")) return explainPermission("post");
492
569
  setMainMode("quote");
493
570
  main.textarea.focus();
@@ -538,11 +615,49 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
538
615
  // and Repost with their counts, as in the Bluesky app.
539
616
  const HEART = '<path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" />';
540
617
  const ARROWS = '<path d="M7 8h10l-3-3M17 16H7l3 3M5 13V9.5A1.5 1.5 0 0 1 6.5 8M19 11v3.5a1.5 1.5 0 0 1-1.5 1.5" />';
541
- const signInFirst = (message: string) => {
542
- this.scrollIntoView({ block: "center", behavior: "smooth" });
543
- say(message);
544
- openLogin();
545
- };
618
+
619
+ // Subscribe: not signed in, the overlay, coming back to subscribe.
620
+ const subscribePanel = this.querySelector<HTMLElement>("[data-subscribe-panel]");
621
+ const askToSubscribe = (problem?: string) => openSignIn(t("subscribe.signIn"), "#atmo-subscribe", problem);
622
+ if (subscribePanel) addSubscribeButtons([...subscribePanel.querySelectorAll<HTMLButtonElement>("[data-atmosphere-subscribe]")], t, askToSubscribe);
623
+
624
+ // The ⌄ menu beside Subscribe: more ways to follow the site.
625
+ const moreButton = subscribePanel?.querySelector<HTMLButtonElement>("[data-subscribe-more]");
626
+ const moreMenu = subscribePanel?.querySelector<HTMLElement>("[data-subscribe-menu]");
627
+ if (subscribePanel && moreButton && moreMenu) {
628
+ const setMenu = (open: boolean) => {
629
+ moreMenu.hidden = !open;
630
+ moreButton.setAttribute("aria-expanded", String(open));
631
+ };
632
+ moreButton.addEventListener("click", () => {
633
+ setMenu(moreMenu.hidden);
634
+ if (!moreMenu.hidden) moreMenu.querySelector<HTMLElement>("[data-subscribe-copy]")?.focus();
635
+ });
636
+ document.addEventListener("click", (event) => {
637
+ if (!subscribePanel.contains(event.target as Node)) setMenu(false);
638
+ });
639
+ subscribePanel.addEventListener("keydown", (event) => {
640
+ if (event.key === "Escape" && !moreMenu.hidden) {
641
+ setMenu(false);
642
+ moreButton.focus();
643
+ }
644
+ });
645
+ const copy = moreMenu.querySelector<HTMLButtonElement>("[data-subscribe-copy]")!;
646
+ const copyText = copy.querySelector<HTMLElement>("[data-copy-label]")!;
647
+ const copyLabel = copyText.textContent;
648
+ copy.addEventListener("click", async () => {
649
+ try {
650
+ await navigator.clipboard.writeText(copy.dataset.subscribeCopy!);
651
+ copyText.textContent = copy.dataset.copied!;
652
+ window.setTimeout(() => {
653
+ copyText.textContent = copyLabel;
654
+ setMenu(false);
655
+ }, 1200);
656
+ } catch {
657
+ // Clipboard refused: the button stays as it was.
658
+ }
659
+ });
660
+ }
546
661
 
547
662
  // What the reader has liked or reposted, by post (filled in by loadState).
548
663
  let known: Record<string, { liked: boolean; reposted: boolean }> = {};
@@ -562,7 +677,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
562
677
  };
563
678
  setCount(count);
564
679
  button.addEventListener("click", async () => {
565
- if (!isSignedIn) return signInFirst(t(kind === "like" ? "signInFirst.like" : "signInFirst.repost"));
680
+ if (!isSignedIn) return askToSignIn(kind === "like" ? "comment-like" : "comment-repost", comment.dataset.atmosphereUri);
566
681
  const on = button.getAttribute("aria-pressed") !== "true";
567
682
  const before = Number(button.dataset.count || 0);
568
683
  button.setAttribute("aria-pressed", String(on));
@@ -595,7 +710,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
595
710
  });
596
711
  reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
597
712
  reply.addEventListener("click", () => {
598
- if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
713
+ if (!isSignedIn) return askToSignIn("reply", comment.dataset.atmosphereUri);
599
714
  if (!can("post")) return explainPermission("post");
600
715
  openReply(comment, author);
601
716
  });
@@ -767,6 +882,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
767
882
  }
768
883
  };
769
884
 
885
+ // Back from the sign-in overlay: finishes what the reader pressed. If
886
+ // signing in failed, the overlay opens again to say so.
887
+ const resumePending = (signed: boolean) => {
888
+ const pending = location.hash.match(/^#atmo-do=([a-z-]+)(?::(.+))?$/);
889
+ if (!pending) return;
890
+ const [, kind, encoded] = pending;
891
+ const uri = encoded ? decodeURIComponent(encoded) : undefined;
892
+ history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
893
+ if (!signed) {
894
+ if (result && result !== "signed-in" && SIGN_IN_HEADINGS[kind]) askToSignIn(kind, uri, message(result));
895
+ return;
896
+ }
897
+ const comment = uri ? document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`) : null;
898
+ const press = (button: HTMLElement | null | undefined) => {
899
+ if (button?.getAttribute("aria-pressed") !== "true") button?.click();
900
+ };
901
+ (comment ?? this).scrollIntoView({ block: "center" });
902
+ if (kind === "like" || kind === "repost") press(reactButton(kind));
903
+ else if (kind === "quote") $<HTMLButtonElement>("[data-quote]").click();
904
+ else if (kind === "comment") main.textarea.focus({ preventScroll: true });
905
+ else if (kind === "reply" && comment) openReply(comment, comment.dataset.atmosphereAuthor || t("comment.someone"));
906
+ else if (kind === "comment-like" && comment) press(comment.querySelector<HTMLElement>(".atmo-comment-action[data-kind='like']"));
907
+ else if (kind === "comment-repost" && comment) press(comment.querySelector<HTMLElement>(".atmo-comment-action[data-kind='repost']"));
908
+ };
909
+
770
910
  const show = async (signed: boolean, handle?: string, displayName?: string, avatar?: string) => {
771
911
  if (handle) {
772
912
  $<HTMLElement>("[data-preview-name]").textContent = displayName || handle;
@@ -778,10 +918,20 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
778
918
  await threadReady;
779
919
  if (this.classList.contains("is-inactive")) return;
780
920
  isSignedIn = signed;
781
- signedOut.hidden = signed;
782
- signedIn.hidden = !signed;
921
+ commentPrompt.hidden = signed;
922
+ if (subscribePanel) {
923
+ subscribePanel.hidden = false;
924
+ if (signed) void loadSubscribed();
925
+ else forgetSubscribed();
926
+ await finishPendingSubscribe(t, askToSubscribe);
927
+ }
783
928
  if (!signed) {
784
929
  document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-action--hide, .atmo-comment-delete").forEach((part) => part.remove());
930
+ composer.hidden = true;
931
+ permissionPanel.hidden = true;
932
+ setPressed("like", false);
933
+ setPressed("repost", false);
934
+ resumePending(false);
785
935
  return closeReplies();
786
936
  }
787
937
  // A sign-in without posting (from an older version, which asked for
@@ -793,6 +943,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
793
943
  addCommentActions();
794
944
  addDeleteButtons();
795
945
  await loadState();
946
+ resumePending(true);
796
947
  // Back from approving the delete or hide permission: ask them to
797
948
  // confirm the delete or hide they started.
798
949
  const pending = location.hash.match(/^#atmo-(delete|hide)=(.+)$/);
@@ -816,7 +967,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
816
967
  history.replaceState(history.state, "", url);
817
968
  }
818
969
 
970
+ // These need this script, so they're hidden without it.
819
971
  this.classList.add("is-loading");
972
+ $<HTMLElement>("[data-view='main']").hidden = false;
820
973
  whoIsSignedIn()
821
974
  .then((found) => {
822
975
  me = found;
@@ -839,12 +992,19 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
839
992
  // Like and Repost: shown as done straight away, put back if it fails.
840
993
  const reactButton = (kind: string) => $<HTMLButtonElement>(`[data-react='${kind}']`);
841
994
  const setPressed = (kind: string, on: boolean) => reactButton(kind).setAttribute("aria-pressed", String(on));
995
+ // Liking also recommends the page's Standard.site record: says so.
996
+ if (subscribePanel && document.querySelector('link[rel="site.standard.document"]')) {
997
+ reactButton("like").title = t("actions.likeAndRecommend");
998
+ }
842
999
  for (const kind of ["like", "repost"]) {
843
1000
  reactButton(kind).addEventListener("click", async () => {
1001
+ if (!isSignedIn) return askToSignIn(kind);
844
1002
  const on = reactButton(kind).getAttribute("aria-pressed") !== "true";
845
1003
  setPressed(kind, on);
846
1004
  try {
847
- const response = await send("react", { post, kind, on });
1005
+ // Liking the post also recommends the page's Standard.site record, if it has one.
1006
+ const document_ = kind === "like" ? document.querySelector('link[rel="site.standard.document"]')?.getAttribute("href") : undefined;
1007
+ const response = await send("react", { post, kind, on, document: document_ ?? undefined });
848
1008
  if (!response.ok) {
849
1009
  setPressed(kind, !on);
850
1010
  const { error, permission } = (await response.json()) as { error?: string; permission?: string };