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
@@ -15,9 +15,17 @@
15
15
  // A link marked `data-atmosphere-follow="<DID>"` (and
16
16
  // `data-atmosphere-follow-handle="you.bsky.social"`), such as "Follow me on
17
17
  // Bluesky", becomes a Follow / Following button for signed-in readers; a
18
- // second press unfollows. The first follow asks for one more permission. Only
18
+ // second press unfollows. Only
19
19
  // `allowedAuthors` can be followed through your site.
20
20
  //
21
+ // With the integration's `standardSite` option, a button marked
22
+ // `data-atmosphere-subscribe` (and `hidden`, as it needs JavaScript) becomes
23
+ // Subscribe / ✓ Subscribed for the site's publication. Anyone not signed in is
24
+ // offered the sign-in form first, and subscribed once they're back. Its label
25
+ // goes in an element inside it marked `data-atmosphere-subscribe-label`;
26
+ // without one it keeps its own content (style it by `aria-pressed`).
27
+ // (AtmosphereComments has its own panel for this under each post.)
28
+ //
21
29
  // Signed-in readers like and repost from your page. Anyone else who presses
22
30
  // Like or Repost is offered the sign-in form in an overlay, and the like or
23
31
  // repost is done once they're back. Only posts by the integration's
@@ -25,7 +33,8 @@
25
33
  //
26
34
  // Props: locale, messages (as for AtmosphereComments).
27
35
  import settings from "../lib/settings";
28
- import { messagesFor, pickLocale, translate } from "../lib/i18n";
36
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
37
+ import { escapeHtml } from "../lib/translate";
29
38
  import SignInForm from "./SignInForm.astro";
30
39
  import Composer from "./Composer.astro";
31
40
  import "./atmosphere.css";
@@ -47,8 +56,27 @@ const { base } = settings;
47
56
  <dialog class="atmo atmo-signin" aria-label={t("dialog.heading")} data-signin-dialog>
48
57
  <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
49
58
  <SignInForm id="atmo-dialog" returnTo={Astro.url.pathname} messages={messages} lang={lang} heading={t("dialog.heading")} />
59
+ <p
60
+ class="atmo__elsewhere"
61
+ data-dialog-elsewhere
62
+ hidden
63
+ set:html={translateHtml(messages, lang, "elsewhere.text", {
64
+ link: `<a target="_blank" rel="noopener" class="atmo__elsewhere-link">${escapeHtml(t("elsewhere.link"))}</a>`,
65
+ })}
66
+ />
50
67
  <p class="atmo-signin__status" role="status" data-dialog-status></p>
51
68
  </dialog>
69
+ {/* Before asking the reader's account host for one more permission: what it's for. */}
70
+ <dialog class="atmo atmo-signin atmo-permission" aria-labelledby="atmo-permission-heading" data-permission-dialog>
71
+ <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
72
+ <p class="atmo-permission__heading" id="atmo-permission-heading">{t("permission.heading")}</p>
73
+ <p data-permission-text></p>
74
+ <p class="atmo__hint">{t("permission.approve")}</p>
75
+ <div class="atmo__row">
76
+ <button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
77
+ <button type="button" class="atmo__link-button" data-close>{t("comment.cancel")}</button>
78
+ </div>
79
+ </dialog>
52
80
  {/* Reply or Quote, for signed-in readers: the comment box in an overlay. */}
53
81
  <dialog class="atmo atmo-signin atmo-compose" aria-label={t("postActions.replyLabel")} data-compose-dialog>
54
82
  <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
@@ -65,6 +93,9 @@ const { base } = settings;
65
93
  import { translate } from "../lib/translate";
66
94
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
67
95
  import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
96
+ import { following, handleSignInForm, me as whoIsSignedIn, send as act, signIn, signOut as endSignIn, state } from "../lib/client/account";
97
+ import { addSubscribeButtons, finishPendingSubscribe, loadSubscribed } from "../lib/client/subscribe";
98
+ import type { Permission } from "../lib/permissions";
68
99
 
69
100
  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" />';
70
101
  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" />';
@@ -83,7 +114,6 @@ const { base } = settings;
83
114
  // Error codes from the routes, and their wording (as in the comments).
84
115
  const MESSAGE_KEYS: Record<string, string> = {
85
116
  "signed-out": "messages.signedOut",
86
- "new-permissions": "messages.newPermissions",
87
117
  "not-allowed": "messages.notAllowed",
88
118
  "too-long": "messages.tooLong",
89
119
  "too-many": "messages.tooMany",
@@ -101,11 +131,30 @@ const { base } = settings;
101
131
  const status = dialog.querySelector<HTMLElement>("[data-dialog-status]")!;
102
132
  let signedIn = false;
103
133
  let handle = "";
104
- let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
134
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
135
+ const can = (permission: Permission) => !!me.can?.includes(permission);
136
+ // One more permission from the reader's account host, then back to
137
+ // `hash` on this page to finish what they pressed. An overlay says
138
+ // what it's for first: the host may ask them to sign in again before
139
+ // it shows what's asked for, which is puzzling without warning.
140
+ const permissionDialog = this.querySelector<HTMLDialogElement>("[data-permission-dialog]")!;
141
+ let pendingPermission: { permission: Permission; hash: string } | null = null;
142
+ const askPermission = (permission: Permission, hash: string, vars: Record<string, string> = {}) => {
143
+ pendingPermission = { permission, hash };
144
+ permissionDialog.querySelector("[data-permission-text]")!.textContent = t(`permission.${permission}`, vars);
145
+ if (!permissionDialog.open) permissionDialog.showModal();
146
+ permissionDialog.querySelector<HTMLButtonElement>("[data-permission-continue]")!.focus();
147
+ };
148
+ permissionDialog.querySelector("[data-permission-continue]")!.addEventListener("click", () => {
149
+ if (!pendingPermission) return;
150
+ const { permission, hash } = pendingPermission;
151
+ void signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
152
+ });
105
153
  // What the reader has liked or reposted, by post.
106
154
  const known: Record<string, { liked: boolean; reposted: boolean }> = {};
107
155
 
108
156
  enhanceHandleBox(form, base);
157
+ handleSignInForm(form, lang);
109
158
  for (const overlay of this.querySelectorAll<HTMLDialogElement>("dialog")) {
110
159
  overlay.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", () => overlay.close()));
111
160
  // A click on the dimmed page around the overlay closes it.
@@ -150,7 +199,11 @@ const { base } = settings;
150
199
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
151
200
  },
152
201
  onError: (code, tell) => {
153
- if (code === "signed-out" || code === "new-permissions") signedIn = false;
202
+ if (code === "signed-out") signedIn = false;
203
+ if (code === "needs-permission") {
204
+ compose.close();
205
+ return void askPermission("post", `#atmo-compose=${composeMode}:${encodeURIComponent(composeRoot)}`);
206
+ }
154
207
  tell(message(code));
155
208
  },
156
209
  onInput: () => quote.update(),
@@ -197,22 +250,29 @@ const { base } = settings;
197
250
  return element;
198
251
  };
199
252
 
200
- // Not signed in: the sign-in form, coming back to finish this like or repost.
201
- const askToSignIn = (kind: Kind, uri: string, problem = "") => {
202
- form.querySelector<HTMLInputElement>("[data-return-to]")!.value =
203
- `${location.pathname}${location.search}#atmo-react=${kind}:${encodeURIComponent(uri)}`;
253
+ // The sign-in overlay: says what it's for, and comes back to `hash`,
254
+ // where what the reader pressed is finished.
255
+ const dialogHeading = form.querySelector("label")!;
256
+ const dialogElsewhere = dialog.querySelector<HTMLElement>("[data-dialog-elsewhere]")!;
257
+ const openSignIn = (heading: string, hash: string, problem = "", blueskyUrl = "") => {
258
+ dialogHeading.textContent = heading;
259
+ dialog.setAttribute("aria-label", heading);
260
+ form.querySelector<HTMLInputElement>("[data-return-to]")!.value = `${location.pathname}${location.search}${hash}`;
204
261
  status.textContent = problem;
262
+ dialogElsewhere.hidden = !blueskyUrl;
263
+ if (blueskyUrl) dialogElsewhere.querySelector("a")!.href = blueskyUrl;
205
264
  if (!dialog.open) dialog.showModal();
206
265
  form.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
207
266
  };
267
+ const blueskyUrlOf = (uri: string) => posts().find((post) => post.dataset.atmospherePost === uri)?.dataset.atmosphereUrl ?? "";
268
+ const SIGN_IN_HEADINGS = { like: "signInFirst.likePost", repost: "signInFirst.repostPost", reply: "signInFirst.reply", quote: "signInFirst.quotePost" };
269
+ // Not signed in: the overlay, coming back to finish this like or repost.
270
+ const askToSignIn = (kind: Kind, uri: string, problem = "") =>
271
+ openSignIn(t(SIGN_IN_HEADINGS[kind]), `#atmo-react=${kind}:${encodeURIComponent(uri)}`, problem, blueskyUrlOf(uri));
272
+ // The same for Reply and Quote, coming back to the comment box.
273
+ const askToSignInToCompose = (mode: "reply" | "quote", uri: string, problem = "") =>
274
+ openSignIn(t(SIGN_IN_HEADINGS[mode]), `#atmo-compose=${mode}:${encodeURIComponent(uri)}`, problem, blueskyUrlOf(uri));
208
275
 
209
- const send = (body: unknown) =>
210
- fetch(`${base}/react`, {
211
- method: "POST",
212
- credentials: "same-origin",
213
- headers: { "content-type": "application/json" },
214
- body: JSON.stringify(body),
215
- });
216
276
 
217
277
  const icon = (paths: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
218
278
 
@@ -228,7 +288,7 @@ const { base } = settings;
228
288
  button.setAttribute("aria-pressed", String(on));
229
289
  setCount(button, Math.max(0, before + (on ? 1 : -1)));
230
290
  try {
231
- const response = await send({ post: uri, kind, on });
291
+ const response = await act("react", { post: uri, kind, on });
232
292
  if (response.ok) {
233
293
  known[uri] = { ...(known[uri] ?? { liked: false, reposted: false }), [kind === "like" ? "liked" : "reposted"]: on };
234
294
  return;
@@ -236,10 +296,12 @@ const { base } = settings;
236
296
  const data = (await response.json().catch(() => ({}))) as { error?: string };
237
297
  button.setAttribute("aria-pressed", String(!on));
238
298
  setCount(button, before);
239
- if (data.error === "signed-out" || data.error === "new-permissions") {
299
+ // Signed in to comment before likes and reposts were asked for.
300
+ if (data.error === "needs-permission") return void askPermission("react", `#atmo-react=${kind}:${encodeURIComponent(uri)}`);
301
+ if (data.error === "signed-out") {
240
302
  signedIn = false;
241
303
  labelButton(button, kind);
242
- askToSignIn(kind, uri, t(data.error === "signed-out" ? "messages.signedOut" : "messages.newPermissions"));
304
+ askToSignIn(kind, uri, t("messages.signedOut"));
243
305
  }
244
306
  } catch {
245
307
  button.setAttribute("aria-pressed", String(!on));
@@ -278,7 +340,8 @@ const { base } = settings;
278
340
  };
279
341
 
280
342
  // Reply and Quote: the comment box in an overlay for signed-in readers,
281
- // otherwise the post on Bluesky (as also with ⌘-click or a middle click).
343
+ // otherwise the sign-in overlay. ⌘-click or a middle click opens the post
344
+ // on Bluesky.
282
345
  // Icons only, as on Bluesky, with their names for screen readers and as
283
346
  // tooltips.
284
347
  const bskyLink = (post: HTMLElement, paths: string) => {
@@ -291,8 +354,11 @@ const { base } = settings;
291
354
  labelLink(link);
292
355
  link.innerHTML = icon(paths);
293
356
  link.addEventListener("click", (event) => {
294
- if (!signedIn || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
357
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
295
358
  event.preventDefault();
359
+ if (!signedIn) return askToSignInToCompose(mode, post.dataset.atmospherePost!);
360
+ // A sign-in without posting (from an older version): asked for first.
361
+ if (!can("post")) return void askPermission("post", `#atmo-compose=${mode}:${encodeURIComponent(post.dataset.atmospherePost!)}`);
296
362
  openCompose(post, mode);
297
363
  });
298
364
  return link;
@@ -323,8 +389,7 @@ const { base } = settings;
323
389
  const uris = posts().map((post) => post.dataset.atmospherePost!);
324
390
  if (!signedIn || !uris.length) return;
325
391
  try {
326
- const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
327
- const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
392
+ const response = await state(uris);
328
393
  if (!response.ok) return;
329
394
  const state = ((await response.json()) as { posts?: typeof known }).posts ?? {};
330
395
  Object.assign(known, state);
@@ -345,17 +410,8 @@ const { base } = settings;
345
410
  const followButtons = new Map<string, { following: boolean; set: (on: boolean) => void; press: () => Promise<void> }>();
346
411
  // Following isn't part of signing in: the first follow asks the
347
412
  // reader's account host for it, then carries on when they're back.
348
- const askFollowPermission = (subject: string) => {
349
- const permit = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
350
- permit.hidden = true;
351
- const field = (name: string, value: string) =>
352
- permit.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
353
- field("handle", me.did || me.handle || "");
354
- field("permission", "follow");
355
- field("returnTo", `${location.pathname}${location.search}#atmo-follow=${encodeURIComponent(subject)}`);
356
- document.body.append(permit);
357
- permit.submit();
358
- };
413
+ const askFollowPermission = (subject: string, who: string) =>
414
+ askPermission("follow", `#atmo-follow=${encodeURIComponent(subject)}`, { handle: who });
359
415
  const setUpFollow = async () => {
360
416
  for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
361
417
  const subject = link.dataset.atmosphereFollow!;
@@ -365,19 +421,19 @@ const { base } = settings;
365
421
  const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
366
422
  const button = make("button", "atmo-follow");
367
423
  button.type = "button";
368
- let following = false;
424
+ let isFollowing = false;
369
425
  // The account's avatar, through the site (its own accounts' are always served).
370
426
  const avatar = make("img", "atmo-follow__avatar");
371
427
  avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
372
428
  avatar.alt = "";
373
429
  avatar.onerror = () => avatar.remove();
374
430
  const render = () => {
375
- button.setAttribute("aria-pressed", String(following));
431
+ button.setAttribute("aria-pressed", String(isFollowing));
376
432
  // The butterfly, then "Follow" with the account's avatar beside its
377
433
  // handle (wherever the language puts the handle), or the avatar
378
434
  // with "Following".
379
435
  button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${BUTTERFLY}</svg>`;
380
- if (following) {
436
+ if (isFollowing) {
381
437
  button.append(avatar, make("span", "", `✓ ${t("follow.following")}`));
382
438
  } else {
383
439
  const [before, after = ""] = t("follow.follow").split("{{handle}}");
@@ -385,47 +441,46 @@ const { base } = settings;
385
441
  button.append(avatar, make("span", "", who));
386
442
  if (after.trim()) button.append(make("span", "", after.trim()));
387
443
  }
388
- button.title = following ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
444
+ button.title = isFollowing ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
389
445
  button.setAttribute("aria-label", button.title);
390
446
  };
391
447
  const set = (on: boolean) => {
392
- following = on;
448
+ isFollowing = on;
393
449
  render();
394
450
  };
395
451
  const press = async () => {
396
452
  button.disabled = true;
397
453
  try {
398
- const response = await fetch(`${base}/follow`, {
399
- method: "POST",
400
- credentials: "same-origin",
401
- headers: { "content-type": "application/json" },
402
- body: JSON.stringify({ subject, on: !following }),
403
- });
454
+ const response = await act("follow", { subject, on: !isFollowing });
404
455
  const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
405
456
  if (response.ok) return set(!!data.following);
406
- if (data.error === "needs-permission") return askFollowPermission(subject);
407
- if (data.error === "signed-out" || data.error === "new-permissions") location.reload();
457
+ if (data.error === "needs-permission") return void askFollowPermission(subject, who);
458
+ if (data.error === "signed-out") location.reload();
408
459
  } finally {
409
460
  button.disabled = false;
410
461
  }
411
462
  };
412
463
  button.addEventListener("click", () => void press());
413
- followButtons.set(subject, { get following() { return following; }, set, press });
464
+ followButtons.set(subject, { get following() { return isFollowing; }, set, press });
414
465
  render();
415
466
  // The button takes the link's place. (Inline, as a site's own style
416
467
  // for the link could otherwise keep it showing.)
417
468
  link.hidden = true;
418
469
  link.style.display = "none";
419
470
  link.after(button);
420
- try {
421
- const response = await fetch(`${base}/follow?subject=${encodeURIComponent(subject)}`, { credentials: "same-origin" });
422
- if (response.ok) set(!!((await response.json()) as { following?: boolean }).following);
423
- } catch {
424
- // Shown as Follow; pressing it still works.
425
- }
471
+ const known = await following(subject);
472
+ if (known !== null) set(known);
426
473
  }
427
474
  };
428
475
 
476
+ // Subscribe buttons the site places (the comments look after their own).
477
+ const askToSubscribe = (problem = "") => openSignIn(t("subscribe.signIn"), "#atmo-subscribe", problem);
478
+ addSubscribeButtons(
479
+ [...document.querySelectorAll<HTMLButtonElement>("[data-atmosphere-subscribe]")].filter((button) => !button.closest("atmosphere-comments")),
480
+ t,
481
+ askToSubscribe,
482
+ );
483
+
429
484
  // Back from approving following: follow as they asked.
430
485
  const finishPendingFollow = async () => {
431
486
  const pending = location.hash.match(/^#atmo-follow=(.+)$/);
@@ -464,6 +519,30 @@ const { base } = settings;
464
519
  if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
465
520
  };
466
521
 
522
+ // Back from signing in or allowing posting: open the Reply or Quote box they pressed.
523
+ const finishPendingCompose = async () => {
524
+ const pending = location.hash.match(/^#atmo-compose=(reply|quote):(.+)$/);
525
+ if (!pending) return;
526
+ const url = new URL(location.href);
527
+ const problem = url.searchParams.get("atmosphere") ?? "";
528
+ if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
529
+ const uri = decodeURIComponent(pending[2]);
530
+ const mode = pending[1] as "reply" | "quote";
531
+ // The page may still be loading its posts (a server island, say).
532
+ for (let tries = 0; tries < 40 && !posts().some((post) => post.dataset.atmospherePost === uri); tries++) {
533
+ await new Promise((resolve) => setTimeout(resolve, 250));
534
+ }
535
+ history.replaceState(history.state, "", `${url.pathname}${url.search}`);
536
+ const post = posts().find((candidate) => candidate.dataset.atmospherePost === uri);
537
+ if (!post) return;
538
+ post.scrollIntoView({ block: "center" });
539
+ if (!signedIn) {
540
+ if (SIGN_IN_PROBLEMS[problem]) askToSignInToCompose(mode, uri, t(SIGN_IN_PROBLEMS[problem]));
541
+ } else if (can("post")) {
542
+ openCompose(post, mode);
543
+ }
544
+ };
545
+
467
546
  // The reader's avatar wherever the page marks data-atmosphere-account,
468
547
  // opening a menu with who they're signed in as and Sign out.
469
548
  const showAccount = (me: { handle?: string; displayName?: string; avatar?: string }) => {
@@ -492,7 +571,7 @@ const { base } = settings;
492
571
  signOut.type = "button";
493
572
  signOut.addEventListener("click", async () => {
494
573
  signOut.disabled = true;
495
- await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
574
+ await endSignIn();
496
575
  location.reload();
497
576
  });
498
577
  menu.append(who, signOut);
@@ -521,9 +600,8 @@ const { base } = settings;
521
600
  }
522
601
  };
523
602
 
524
- fetch(`${base}/me`, { credentials: "same-origin" })
525
- .then((response) => response.json())
526
- .then((found: { signedIn?: boolean; handle?: string; displayName?: string; avatar?: string }) => {
603
+ whoIsSignedIn()
604
+ .then((found) => {
527
605
  me = found;
528
606
  signedIn = !!found.signedIn;
529
607
  handle = found.handle ? `@${found.handle}` : "";
@@ -542,10 +620,13 @@ const { base } = settings;
542
620
  addActions();
543
621
  await loadState();
544
622
  await finishPending();
623
+ await finishPendingCompose();
545
624
  if (signedIn) {
546
625
  await setUpFollow();
547
626
  await finishPendingFollow();
627
+ await loadSubscribed();
548
628
  }
629
+ await finishPendingSubscribe(t, askToSubscribe);
549
630
  });
550
631
 
551
632
  // Posts that arrive later (a server island, say) get buttons too.
@@ -562,38 +643,14 @@ const { base } = settings;
562
643
  </script>
563
644
 
564
645
  <style>
565
- .atmo-signin {
566
- /* Centred on the screen, whatever margin a site gives .atmo. */
567
- margin: auto;
568
- width: min(92vw, 520px);
569
- padding: 22px 22px 18px;
570
- border: 1px solid var(--atmo-border, #8886);
571
- border-radius: calc(var(--atmo-radius, 10px) + 6px);
572
- background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
573
- color: var(--atmo-text, CanvasText);
574
- box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
575
- }
576
-
577
- .atmo-signin::backdrop {
578
- background: rgb(0 0 0 / 0.55);
579
- }
580
-
581
- .atmo-signin :global(.atmo__login) {
582
- margin-top: 0;
646
+ .atmo-permission p {
647
+ margin: 0 0 10px;
583
648
  }
584
649
 
585
- .atmo-signin__close {
586
- position: absolute;
587
- top: 8px;
588
- right: 10px;
589
- padding: 4px 8px;
590
- border: 0;
591
- background: none;
592
- color: var(--atmo-muted, inherit);
593
- font: inherit;
594
- font-size: 1.4em;
595
- line-height: 1;
596
- cursor: pointer;
650
+ .atmo-permission__heading {
651
+ /* Clear of the close button. */
652
+ padding-right: 32px;
653
+ font-weight: 700;
597
654
  }
598
655
 
599
656
  .atmo-compose {
@@ -608,16 +665,6 @@ const { base } = settings;
608
665
  margin: 0 0 12px;
609
666
  }
610
667
 
611
- .atmo-signin__status {
612
- margin: 10px 0 0;
613
- color: var(--atmo-muted, inherit);
614
- font-size: 0.9em;
615
- }
616
-
617
- .atmo-signin__status:empty {
618
- display: none;
619
- }
620
-
621
668
  /* The row under each post. Defaults carry no weight, so a site's own
622
669
  rules always win. */
623
670
  :global(:where(.atmo-post-actions)) {
@@ -56,7 +56,7 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
56
56
  <div class="atmo__quoted">
57
57
  <p class="atmo__preview-author">
58
58
  <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
59
- <strong data-quoted-name></strong> <span data-quoted-handle></span>
59
+ <strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
60
60
  </p>
61
61
  <p class="atmo__preview-text" data-quoted-text></p>
62
62
  <div class="atmo__card" data-card hidden>
@@ -64,7 +64,10 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
64
64
  <div>
65
65
  <strong data-card-title></strong>
66
66
  <span data-card-description></span>
67
- <span class="atmo__card-domain" data-card-domain></span>
67
+ <span class="atmo__card-domain">
68
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
69
+ <span data-card-domain></span>
70
+ </span>
68
71
  </div>
69
72
  </div>
70
73
  </div>
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  // The sign-in form: a handle box (which suggests matching accounts, see
3
3
  // lib/client/handle-suggestions.ts) and a "Sign in with Atmosphere" button.
4
+ // Signing in happens in the browser (handleSignInForm in
5
+ // lib/client/account.ts), so the form needs JavaScript.
4
6
  // Used under posts by AtmosphereComments and in the sign-in overlay of
5
7
  // AtmosphereReactions; not meant to be used on its own.
6
8
  //
@@ -9,7 +11,10 @@
9
11
  // returnTo where the reader comes back to after signing in
10
12
  // messages, lang the wording (see lib/i18n.ts)
11
13
  // heading the form's heading, instead of "signIn.heading"
14
+ // permissions more to ask for (groups in lib/permissions.ts) on top of
15
+ // everything readers can do here (DEFAULT_PERMISSIONS)
12
16
  import logo from "../assets/union.svg?url";
17
+ import type { Permission } from "../lib/permissions";
13
18
  import settings from "../lib/settings";
14
19
  import { translate, translateHtml } from "../lib/i18n";
15
20
  import { escapeHtml } from "../lib/translate";
@@ -20,10 +25,11 @@ interface Props {
20
25
  messages: Record<string, string>;
21
26
  lang: string;
22
27
  heading?: string;
28
+ permissions?: Permission[];
23
29
  }
24
30
 
25
- const { id, returnTo, messages, lang, heading } = Astro.props;
26
- const { base } = settings;
31
+ const { id, returnTo, messages, lang, heading, permissions = [] } = Astro.props;
32
+ const { policyUri } = settings;
27
33
  const t = (key: string) => translate(messages, lang, key);
28
34
  const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
29
35
  // Links lead off the page, so they open in a new tab.
@@ -31,13 +37,14 @@ const link = (href: string, text: string) =>
31
37
  `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
32
38
  ---
33
39
 
34
- <form class="atmo__login" id={`${id}-login`} method="post" action={`${base}/login`} data-login-form>
40
+ <form class="atmo__login" id={`${id}-login`} data-login-form>
35
41
  <label for={`${id}-handle`}>{heading ?? t("signIn.heading")}</label>
36
42
  <p
37
43
  class="atmo__hint"
38
44
  set:html={th("signIn.anyAccount", {
39
45
  atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
40
46
  bluesky: link("https://bsky.app", "Bluesky"),
47
+ createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")),
41
48
  })}
42
49
  />
43
50
  <div class="atmo__row">
@@ -67,15 +74,22 @@ const link = (href: string, text: string) =>
67
74
  ></ul>
68
75
  </span>
69
76
  <input type="hidden" name="returnTo" value={returnTo} data-return-to />
77
+ {permissions.map((permission) => <input type="hidden" name="permission" value={permission} />)}
70
78
  <button type="submit" class="atmo__button atmo__button--sign-in">
71
79
  <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
80
+ <span class="atmo__spinner" aria-hidden="true"></span>
72
81
  {t("signIn.button")}
73
82
  </button>
74
83
  </div>
75
- <p
76
- class="atmo__hint"
77
- set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
78
- />
84
+ {/* The detail, for anyone who wants it. */}
85
+ <details class="atmo__details">
86
+ <summary>{t("signIn.more")}</summary>
87
+ <p class="atmo__hint">{t("signIn.privacy")}</p>
88
+ <p class="atmo__hint">
89
+ {t("signIn.access")}
90
+ {policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
91
+ </p>
92
+ </details>
79
93
  </form>
80
94
 
81
95
  <style>
@@ -94,6 +108,17 @@ const link = (href: string, text: string) =>
94
108
  font-weight: 700;
95
109
  }
96
110
 
111
+ .atmo__details summary {
112
+ width: fit-content;
113
+ color: var(--atmo-muted, inherit);
114
+ font-size: 0.85em;
115
+ cursor: pointer;
116
+ }
117
+
118
+ .atmo__details .atmo__hint {
119
+ margin-top: 6px;
120
+ }
121
+
97
122
  .atmo__hint {
98
123
  margin: 0;
99
124
  color: var(--atmo-muted, inherit);
@@ -154,6 +179,39 @@ const link = (href: string, text: string) =>
154
179
  cursor: pointer;
155
180
  }
156
181
 
182
+ /* While signing in, a spinner takes the logo's place, so the button keeps its size. */
183
+ .atmo__spinner {
184
+ display: none;
185
+ flex: none;
186
+ box-sizing: border-box;
187
+ width: 20px;
188
+ height: 20px;
189
+ border: 2px solid currentColor;
190
+ border-right-color: transparent;
191
+ border-radius: 50%;
192
+ animation: atmo-spin 700ms linear infinite;
193
+ }
194
+
195
+ [data-signing-in] .atmo__spinner {
196
+ display: inline-block;
197
+ }
198
+
199
+ [data-signing-in] .atmo__button .atmo__logo {
200
+ display: none;
201
+ }
202
+
203
+ @keyframes atmo-spin {
204
+ to {
205
+ transform: rotate(360deg);
206
+ }
207
+ }
208
+
209
+ @media (prefers-reduced-motion: reduce) {
210
+ .atmo__spinner {
211
+ animation-duration: 2.5s;
212
+ }
213
+ }
214
+
157
215
  .atmo__button .atmo__logo {
158
216
  flex: none;
159
217
  width: 20px;