astro-atmosphere-comments 0.4.2 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +36 -27
  3. package/components/AtmosphereComments.astro +378 -117
  4. package/components/AtmosphereReactions.astro +145 -98
  5. package/components/Composer.astro +5 -2
  6. package/components/SignInForm.astro +65 -7
  7. package/components/atmosphere.css +267 -2
  8. package/index.d.ts +11 -0
  9. package/index.mjs +53 -32
  10. package/lib/client/account.ts +661 -0
  11. package/lib/client/composer.ts +23 -10
  12. package/lib/client/subscribe.ts +106 -0
  13. package/lib/http.ts +28 -0
  14. package/lib/permissions.ts +132 -0
  15. package/lib/public-settings.ts +20 -0
  16. package/lib/quoted.ts +4 -0
  17. package/lib/settings.ts +2 -0
  18. package/lib/thread.ts +36 -6
  19. package/lib/uris.ts +39 -0
  20. package/locales/de.json +20 -4
  21. package/locales/en.json +48 -13
  22. package/locales/es.json +20 -4
  23. package/locales/fr.json +20 -4
  24. package/locales/it.json +20 -4
  25. package/locales/ja.json +20 -4
  26. package/locales/nl.json +20 -4
  27. package/locales/pt.json +20 -4
  28. package/package.json +2 -7
  29. package/routes/callback.astro +42 -0
  30. package/routes/client-metadata.json.ts +3 -2
  31. package/routes/following.ts +28 -0
  32. package/routes/identity.ts +69 -0
  33. package/routes/media.ts +7 -10
  34. package/routes/post.ts +2 -1
  35. package/routes/profile.ts +20 -0
  36. package/routes/richtext.ts +30 -0
  37. package/routes/thread.ts +23 -3
  38. package/routes/typeahead.ts +7 -23
  39. package/virtual.d.ts +5 -0
  40. package/bin/cli.mjs +0 -31
  41. package/lib/oauth.ts +0 -110
  42. package/lib/reader.ts +0 -130
  43. package/lib/store.ts +0 -46
  44. package/lib/visitor.ts +0 -42
  45. package/routes/callback.ts +0 -30
  46. package/routes/comment.ts +0 -97
  47. package/routes/delete.ts +0 -41
  48. package/routes/follow.ts +0 -71
  49. package/routes/jwks.json.ts +0 -13
  50. package/routes/login.ts +0 -58
  51. package/routes/logout.ts +0 -20
  52. package/routes/me.ts +0 -12
  53. package/routes/react.ts +0 -48
  54. package/routes/state.ts +0 -24
@@ -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,11 +110,72 @@ 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
+ )}
138
+ </div>
139
+
140
+ {/* A sign-in from before posting was asked for: one more permission. */}
141
+ <div class="atmo__permission" data-permission hidden>
142
+ <p data-permission-text>{t("composer.needsPermission")}</p>
143
+ <p class="atmo__hint">{t("permission.approve")}</p>
144
+ <div class="atmo__row">
145
+ <button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
146
+ <button type="button" class="atmo__link-button" data-permission-sign-out>{t("composer.signOut")}</button>
147
+ </div>
148
+ </div>
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>
122
157
  </div>
123
158
 
124
159
  <Composer id="atmo" messages={messages} lang={lang} signOut />
125
160
  </div>
126
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
+
127
179
  <p class="atmo__status" data-status role="status" data-pagefind-ignore></p>
128
180
 
129
181
  {/* Anything the site wants between the comment box and the comments. */}
@@ -143,6 +195,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
143
195
  import { translate } from "../lib/translate";
144
196
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
145
197
  import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
198
+ import { handleSignInForm, me as whoIsSignedIn, send, signIn, signOut, state } from "../lib/client/account";
199
+ import { addSubscribeButtons, finishPendingSubscribe, forgetSubscribed, loadSubscribed } from "../lib/client/subscribe";
200
+ import type { Permission } from "../lib/permissions";
201
+ import { authorOf } from "../lib/uris";
146
202
 
147
203
 
148
204
  // The error and status codes the routes and the sign-in round trip send,
@@ -155,7 +211,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
155
211
  "signed-out": "messages.signedOut",
156
212
  "needs-permission": "messages.needsPermission",
157
213
  "not-allowed": "messages.notAllowed",
158
- "new-permissions": "messages.newPermissions",
159
214
  "too-long": "messages.tooLong",
160
215
  "too-many": "messages.tooMany",
161
216
  error: "messages.error",
@@ -174,26 +229,34 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
174
229
  // Standard.site record is turned into one when the thread loads.
175
230
  let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
176
231
  const $ = <T extends Element>(selector: string) => this.querySelector<T>(selector)!;
177
- const signedOut = $<HTMLElement>("[data-view='signed-out']");
178
- const signedIn = $<HTMLElement>("[data-view='signed-in']");
232
+ const commentPrompt = $<HTMLElement>("[data-comment-prompt]");
179
233
  const composer = $<HTMLFormElement>("[data-composer]");
234
+ const permissionPanel = $<HTMLElement>("[data-permission]");
180
235
  // A clean copy of the comment box, for reply boxes under comments.
181
236
  const template = composer.cloneNode(true) as HTMLFormElement;
182
237
  const loginForm = $<HTMLFormElement>("[data-login-form]");
183
238
  const status = $<HTMLElement>("[data-status]");
184
239
  let isSignedIn = false;
185
- let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
240
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
241
+ const can = (permission: Permission) => !!me.can?.includes(permission);
186
242
  // What the main box is doing: a comment on the post, or a quote post.
187
243
  let mainMode: "comment" | "quote" = "comment";
188
244
 
189
245
  const say = (message: string) => (status.textContent = message);
190
- const send = (path: string, body: unknown) =>
191
- fetch(`${base}/${path}`, {
192
- method: "POST",
193
- credentials: "same-origin",
194
- headers: { "content-type": "application/json" },
195
- body: JSON.stringify(body),
196
- });
246
+ // Sends the reader to their account host for one more permission,
247
+ // coming back to `hash` on this page (to finish what they asked).
248
+ const askPermission = (permission: Permission, hash = "#atmosphere-comments") =>
249
+ signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
250
+ // Says what the permission is for before going (the account host may
251
+ // ask the reader to sign in again first, which is puzzling unexplained),
252
+ // with Continue to approve. Delete and Hide do the same in their panels.
253
+ const explainPermission = (permission: "post" | "react") => {
254
+ const go = Object.assign(make("button", "atmo__link-button", t("comment.continue")), { type: "button" });
255
+ go.addEventListener("click", () => void askPermission(permission));
256
+ status.replaceChildren(`${t(`permission.${permission}`)} ${t("permission.approve")} `, go);
257
+ status.scrollIntoView({ block: "nearest", behavior: "smooth" });
258
+ go.focus({ preventScroll: true });
259
+ };
197
260
  // Elements this script creates get the component's marker attribute, so
198
261
  // Astro's scoped styles still reach them.
199
262
  const scoped = <T extends HTMLElement>(element: T): T => {
@@ -216,6 +279,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
216
279
  depth: number;
217
280
  likeCount: number;
218
281
  repostCount: number;
282
+ withheld?: true;
219
283
  };
220
284
  type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
221
285
  let thread: Thread | null = null;
@@ -272,7 +336,17 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
272
336
  const replyElement = (reply: Reply) => {
273
337
  const item = make("li", `atmo-comment${reply.byAuthor ? " atmo-comment--author" : ""}${reply.depth ? " atmo-comment--nested" : ""}`);
274
338
  if (reply.depth) item.style.setProperty("--depth", String(Math.min(reply.depth, 3)));
339
+ // Its author asks not to be shown to logged-out people: a placeholder,
340
+ // keeping the replies under it in place. (No AT URI, so no actions.)
341
+ if (reply.withheld) {
342
+ item.classList.add("atmo-comment--withheld");
343
+ const article = make("article");
344
+ article.append(make("p", "atmo-comment__withheld", t("comment.withheld")));
345
+ item.append(article);
346
+ return item;
347
+ }
275
348
  item.dataset.atmosphereUri = reply.uri;
349
+ item.dataset.atmosphereUrl = reply.url;
276
350
  item.dataset.atmosphereAuthor = nameOf(reply.author);
277
351
  item.dataset.atmosphereLikes = String(reply.likeCount);
278
352
  item.dataset.atmosphereReposts = String(reply.repostCount);
@@ -310,7 +384,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
310
384
  // Nothing to respond to: hide the box and likes, but keep whatever the
311
385
  // site put in the slots.
312
386
  const deactivate = () => {
313
- 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]")) {
314
388
  part.hidden = true;
315
389
  }
316
390
  this.classList.add("is-inactive");
@@ -332,6 +406,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
332
406
  }
333
407
  thread = data.thread;
334
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
+ }
335
414
  const elsewhere = this.querySelector<HTMLElement>("[data-elsewhere]");
336
415
  if (elsewhere && thread.url) {
337
416
  elsewhere.querySelector("a")!.href = thread.url;
@@ -382,9 +461,39 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
382
461
  renderReactions();
383
462
  };
384
463
 
385
- 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")));
386
494
 
387
495
  enhanceHandleBox(loginForm, base);
496
+ handleSignInForm(loginForm, lang);
388
497
 
389
498
  // An avatar picture, or the name's first letter if there isn't one.
390
499
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -395,8 +504,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
395
504
  img.onload = () => slot.replaceChildren(img);
396
505
  };
397
506
 
398
- const handleError = (error: string, tell: (message: string) => void = say) => {
399
- if (error === "signed-out" || error === "new-permissions") void show(false);
507
+ const handleError = (error: string, tell: (message: string) => void = say, permission?: string) => {
508
+ if (error === "signed-out") void show(false);
509
+ // The page checks before it starts, so this is rare: ask now.
510
+ if (error === "needs-permission" && (permission === "post" || permission === "react")) return explainPermission(permission);
400
511
  tell(message(error));
401
512
  };
402
513
 
@@ -453,6 +564,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
453
564
  main.textarea.focus();
454
565
  });
455
566
  $<HTMLButtonElement>("[data-quote]").addEventListener("click", () => {
567
+ if (!isSignedIn) return askToSignIn("quote");
568
+ if (!can("post")) return explainPermission("post");
456
569
  setMainMode("quote");
457
570
  main.textarea.focus();
458
571
  });
@@ -502,11 +615,49 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
502
615
  // and Repost with their counts, as in the Bluesky app.
503
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" />';
504
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" />';
505
- const signInFirst = (message: string) => {
506
- this.scrollIntoView({ block: "center", behavior: "smooth" });
507
- say(message);
508
- openLogin();
509
- };
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
+ }
510
661
 
511
662
  // What the reader has liked or reposted, by post (filled in by loadState).
512
663
  let known: Record<string, { liked: boolean; reposted: boolean }> = {};
@@ -526,7 +677,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
526
677
  };
527
678
  setCount(count);
528
679
  button.addEventListener("click", async () => {
529
- 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);
530
681
  const on = button.getAttribute("aria-pressed") !== "true";
531
682
  const before = Number(button.dataset.count || 0);
532
683
  button.setAttribute("aria-pressed", String(on));
@@ -537,7 +688,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
537
688
  } catch (problem) {
538
689
  button.setAttribute("aria-pressed", String(!on));
539
690
  setCount(before);
540
- handleError((problem as { error?: string })?.error ?? "error");
691
+ const { error, permission } = (problem ?? {}) as { error?: string; permission?: string };
692
+ handleError(error ?? "error", say, permission);
541
693
  }
542
694
  });
543
695
  return button;
@@ -558,7 +710,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
558
710
  });
559
711
  reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
560
712
  reply.addEventListener("click", () => {
561
- if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
713
+ if (!isSignedIn) return askToSignIn("reply", comment.dataset.atmosphereUri);
714
+ if (!can("post")) return explainPermission("post");
562
715
  openReply(comment, author);
563
716
  });
564
717
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
@@ -583,7 +736,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
583
736
  this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
584
737
  return;
585
738
  }
586
- if (data.error === "needs-permission") return askDeletePermission(comment, panel);
739
+ // The page checks first, so this is rare: ask now.
740
+ if (data.error === "needs-permission") return void askPermission("delete", `#atmo-delete=${encodeURIComponent(uri)}`);
587
741
  panel.remove();
588
742
  handleError(data.error ?? "error");
589
743
  } catch {
@@ -592,58 +746,112 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
592
746
  }
593
747
  };
594
748
 
595
- // Deleting posts isn't part of signing in, so the first delete asks the
596
- // reader's account host for it, then carries on when they come back.
597
- const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
598
- const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
599
- const field = (name: string, value: string) =>
600
- form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
601
- field("handle", me.did!);
602
- field("permission", "delete");
603
- field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
604
- const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
605
- const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
606
- cancel.addEventListener("click", () => panel.remove());
607
- form.append(go, cancel);
608
- panel.replaceChildren(
609
- make(
610
- "span",
611
- "atmo-comment-delete__text",
612
- t("comment.deletePermission"),
613
- ),
614
- form,
615
- );
616
- go.focus();
749
+ // Hide, for the announcement's own author: hides a comment here and on
750
+ // Bluesky. Showing it again is done in the Bluesky app.
751
+ const isOwner = () => !!me.did && !!post && authorOf(post) === me.did;
752
+
753
+ const hideComment = async (comment: HTMLElement, panel: HTMLElement) => {
754
+ const uri = comment.dataset.atmosphereUri!;
755
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.hiding")));
756
+ try {
757
+ const response = await send("hide", { post: uri, root: post });
758
+ const data = (await response.json()) as { error?: string };
759
+ if (response.ok) {
760
+ comment.remove();
761
+ say(t("comment.hidden"));
762
+ this.dispatchEvent(new CustomEvent("atmosphere:hidden", { detail: { uri }, bubbles: true }));
763
+ return;
764
+ }
765
+ if (data.error === "needs-permission") return void askPermission("moderate", `#atmo-hide=${encodeURIComponent(uri)}`);
766
+ panel.remove();
767
+ handleError(data.error ?? "error");
768
+ } catch {
769
+ panel.remove();
770
+ say(message("error"));
771
+ }
617
772
  };
618
773
 
619
- const deleteButton = (comment: HTMLElement) => {
620
- const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
621
- button.setAttribute("aria-label", t("comment.deleteLabel"));
622
- button.addEventListener("click", () => {
623
- const actions = button.parentElement!;
624
- if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
625
- const panel = make("div", "atmo-comment-delete");
626
- const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
627
- const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
628
- yes.addEventListener("click", () => deleteComment(comment, panel));
629
- no.addEventListener("click", () => {
630
- panel.remove();
631
- button.focus();
632
- });
633
- panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
634
- panel.setAttribute("role", "group");
635
- panel.setAttribute("aria-label", t("comment.deleteLabel"));
774
+ // Delete and Hide each need one more permission than signing in gives.
775
+ // Readers who haven't given it yet are asked for it first; once they
776
+ // have (straight away, or back from approving), they confirm.
777
+ const STEPS = {
778
+ delete: {
779
+ permission: "delete",
780
+ action: "comment.delete",
781
+ label: "comment.deleteLabel",
782
+ confirm: "comment.deleteConfirm",
783
+ needs: "comment.deletePermission",
784
+ // "{{link}}" in the wording: the comment on Bluesky, to delete it there instead.
785
+ elsewhere: "comment.deleteElsewhere",
786
+ run: deleteComment,
787
+ },
788
+ hide: {
789
+ permission: "moderate",
790
+ action: "comment.hide",
791
+ label: "comment.hideLabel",
792
+ confirm: "comment.hideConfirm",
793
+ needs: "comment.hidePermission",
794
+ elsewhere: "",
795
+ run: hideComment,
796
+ },
797
+ } as const;
798
+
799
+ const openPanel = (comment: HTMLElement, kind: keyof typeof STEPS) => {
800
+ const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
801
+ if (!actions || actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
802
+ const step = STEPS[kind];
803
+ const panel = make("div", "atmo-comment-delete");
804
+ panel.setAttribute("role", "group");
805
+ panel.setAttribute("aria-label", t(step.label));
806
+ const button = (text: string, danger = false) =>
807
+ Object.assign(make("button", `atmo-comment-action${danger ? " atmo-comment-action--danger" : ""}`, text), { type: "button" });
808
+ const no = button(t("comment.cancel"));
809
+ no.addEventListener("click", () => {
810
+ panel.remove();
811
+ actions.querySelector<HTMLElement>(`.atmo-comment-action--${kind}`)?.focus();
812
+ });
813
+ if (can(step.permission)) {
814
+ const yes = button(t(step.action), true);
815
+ yes.addEventListener("click", () => step.run(comment, panel));
816
+ panel.append(make("span", "atmo-comment-delete__text", t(step.confirm)), yes, no);
636
817
  actions.after(panel);
637
818
  no.focus();
638
- });
819
+ } else {
820
+ const go = button(t("comment.continue"), true);
821
+ go.addEventListener("click", () =>
822
+ void askPermission(step.permission, `#atmo-${kind}=${encodeURIComponent(comment.dataset.atmosphereUri!)}`),
823
+ );
824
+ const text = make("span", "atmo-comment-delete__text");
825
+ const [before, after = ""] = t(step.needs, { link: "\u0000" }).split("\u0000");
826
+ text.append(before);
827
+ if (step.elsewhere) {
828
+ const url = comment.dataset.atmosphereUrl;
829
+ text.append(
830
+ url
831
+ ? Object.assign(make("a", "", t(step.elsewhere)), { href: url, target: "_blank", rel: "noopener noreferrer" })
832
+ : t(step.elsewhere),
833
+ after,
834
+ );
835
+ }
836
+ panel.append(text, go, no);
837
+ actions.after(panel);
838
+ go.focus();
839
+ }
840
+ };
841
+
842
+ const panelButton = (comment: HTMLElement, kind: keyof typeof STEPS) => {
843
+ const button = Object.assign(make("button", `atmo-comment-action atmo-comment-action--${kind}`, t(STEPS[kind].action)), { type: "button" });
844
+ button.setAttribute("aria-label", t(STEPS[kind].label));
845
+ button.addEventListener("click", () => openPanel(comment, kind));
639
846
  return button;
640
847
  };
641
848
 
642
849
  const addDeleteButtons = () => {
643
850
  document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
644
851
  const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
645
- if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
646
- if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
852
+ if (!actions || actions.querySelector(".atmo-comment-action--delete, .atmo-comment-action--hide")) return;
853
+ if (isSignedIn && isMine(comment)) actions.append(panelButton(comment, "delete"));
854
+ else if (isSignedIn && isOwner()) actions.append(panelButton(comment, "hide"));
647
855
  });
648
856
  };
649
857
 
@@ -653,14 +861,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
653
861
  const comments = [...document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]")];
654
862
  const uris = [post, ...comments.map((comment) => comment.dataset.atmosphereUri!)];
655
863
  try {
656
- const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
657
- const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
658
- const state = (await response.json()) as {
864
+ const response = await state(uris);
865
+ const found = (await response.json()) as {
659
866
  error?: string;
660
867
  posts?: Record<string, { liked: boolean; reposted: boolean }>;
661
868
  };
662
- if (!response.ok) return handleError(state.error ?? "error");
663
- const posts = state.posts ?? {};
869
+ if (!response.ok) return handleError(found.error ?? "error");
870
+ const posts = found.posts ?? {};
664
871
  known = { ...known, ...posts };
665
872
  setPressed("like", !!posts[post]?.liked);
666
873
  setPressed("repost", !!posts[post]?.reposted);
@@ -675,6 +882,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
675
882
  }
676
883
  };
677
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
+
678
910
  const show = async (signed: boolean, handle?: string, displayName?: string, avatar?: string) => {
679
911
  if (handle) {
680
912
  $<HTMLElement>("[data-preview-name]").textContent = displayName || handle;
@@ -686,26 +918,42 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
686
918
  await threadReady;
687
919
  if (this.classList.contains("is-inactive")) return;
688
920
  isSignedIn = signed;
689
- signedOut.hidden = signed;
690
- 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
+ }
691
928
  if (!signed) {
692
- document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
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);
693
935
  return closeReplies();
694
936
  }
937
+ // A sign-in without posting (from an older version, which asked for
938
+ // less): one more permission, asked for before they type.
939
+ composer.hidden = !can("post");
940
+ permissionPanel.hidden = can("post");
941
+ // Which account, as they may have more than one.
942
+ $<HTMLElement>("[data-permission-text]").textContent = t("composer.needsPermission", { handle: handle ? `@${handle}` : "" });
695
943
  addCommentActions();
944
+ addDeleteButtons();
696
945
  await loadState();
697
- // Back from approving the delete permission: finish the delete they asked for.
698
- const pending = location.hash.match(/^#atmo-delete=(.+)$/);
946
+ resumePending(true);
947
+ // Back from approving the delete or hide permission: ask them to
948
+ // confirm the delete or hide they started.
949
+ const pending = location.hash.match(/^#atmo-(delete|hide)=(.+)$/);
699
950
  if (pending) {
700
951
  history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
701
- const uri = decodeURIComponent(pending[1]);
702
- const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
703
- const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
704
- if (comment && actions && isMine(comment)) {
705
- const panel = make("div", "atmo-comment-delete");
706
- actions.after(panel);
952
+ const kind = pending[1] as keyof typeof STEPS;
953
+ const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(decodeURIComponent(pending[2]))}"]`);
954
+ if (comment && (kind === "delete" ? isMine(comment) : isOwner())) {
707
955
  comment.scrollIntoView({ block: "center" });
708
- await deleteComment(comment, panel);
956
+ openPanel(comment, kind);
709
957
  }
710
958
  }
711
959
  };
@@ -719,12 +967,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
719
967
  history.replaceState(history.state, "", url);
720
968
  }
721
969
 
970
+ // These need this script, so they're hidden without it.
722
971
  this.classList.add("is-loading");
723
- fetch(`${base}/me`, { credentials: "same-origin" })
724
- .then((response) => response.json())
725
- .then((found: { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string }) => {
972
+ $<HTMLElement>("[data-view='main']").hidden = false;
973
+ whoIsSignedIn()
974
+ .then((found) => {
726
975
  me = found;
727
- return show(found.signedIn, found.handle, found.displayName, found.avatar);
976
+ return found.signedIn ? show(true, found.handle, found.displayName, found.avatar) : show(false);
728
977
  })
729
978
  .catch(() => show(false))
730
979
  .finally(() => {
@@ -743,15 +992,23 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
743
992
  // Like and Repost: shown as done straight away, put back if it fails.
744
993
  const reactButton = (kind: string) => $<HTMLButtonElement>(`[data-react='${kind}']`);
745
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
+ }
746
999
  for (const kind of ["like", "repost"]) {
747
1000
  reactButton(kind).addEventListener("click", async () => {
1001
+ if (!isSignedIn) return askToSignIn(kind);
748
1002
  const on = reactButton(kind).getAttribute("aria-pressed") !== "true";
749
1003
  setPressed(kind, on);
750
1004
  try {
751
- 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 });
752
1008
  if (!response.ok) {
753
1009
  setPressed(kind, !on);
754
- return handleError((await response.json()).error);
1010
+ const { error, permission } = (await response.json()) as { error?: string; permission?: string };
1011
+ return handleError(error ?? "error", say, permission);
755
1012
  }
756
1013
  say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
757
1014
  showReaction(kind, on);
@@ -768,12 +1025,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
768
1025
  });
769
1026
  }
770
1027
 
771
- $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", async () => {
772
- await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
1028
+ const signOutHere = async () => {
1029
+ await signOut();
1030
+ me = {};
773
1031
  void show(false);
774
1032
  setMainMode("comment");
775
1033
  say(t("status.signedOut"));
776
- });
1034
+ };
1035
+ $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", signOutHere);
1036
+ $<HTMLButtonElement>("[data-permission-sign-out]").addEventListener("click", signOutHere);
1037
+ $<HTMLButtonElement>("[data-permission-continue]").addEventListener("click", () => void askPermission("post"));
777
1038
  }
778
1039
  }
779
1040