astro-atmosphere-comments 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +21 -23
  3. package/components/AtmosphereComments.astro +179 -78
  4. package/components/AtmosphereReactions.astro +86 -51
  5. package/components/SignInForm.astro +14 -3
  6. package/components/atmosphere.css +21 -0
  7. package/index.mjs +42 -32
  8. package/lib/client/account.ts +553 -0
  9. package/lib/client/composer.ts +7 -10
  10. package/lib/http.ts +28 -0
  11. package/lib/permissions.ts +108 -0
  12. package/lib/public-settings.ts +18 -0
  13. package/lib/thread.ts +36 -6
  14. package/lib/uris.ts +31 -0
  15. package/locales/de.json +20 -4
  16. package/locales/en.json +20 -4
  17. package/locales/es.json +20 -4
  18. package/locales/fr.json +20 -4
  19. package/locales/it.json +20 -4
  20. package/locales/ja.json +20 -4
  21. package/locales/nl.json +20 -4
  22. package/locales/pt.json +20 -4
  23. package/package.json +2 -7
  24. package/routes/callback.astro +42 -0
  25. package/routes/client-metadata.json.ts +3 -2
  26. package/routes/following.ts +28 -0
  27. package/routes/identity.ts +69 -0
  28. package/routes/media.ts +7 -10
  29. package/routes/post.ts +1 -1
  30. package/routes/profile.ts +20 -0
  31. package/routes/richtext.ts +30 -0
  32. package/routes/thread.ts +23 -3
  33. package/routes/typeahead.ts +7 -23
  34. package/virtual.d.ts +5 -0
  35. package/bin/cli.mjs +0 -31
  36. package/lib/oauth.ts +0 -110
  37. package/lib/reader.ts +0 -130
  38. package/lib/store.ts +0 -46
  39. package/lib/visitor.ts +0 -42
  40. package/routes/callback.ts +0 -30
  41. package/routes/comment.ts +0 -97
  42. package/routes/delete.ts +0 -41
  43. package/routes/follow.ts +0 -71
  44. package/routes/jwks.json.ts +0 -13
  45. package/routes/login.ts +0 -58
  46. package/routes/logout.ts +0 -20
  47. package/routes/me.ts +0 -12
  48. package/routes/react.ts +0 -48
  49. package/routes/state.ts +0 -24
@@ -15,7 +15,7 @@
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
21
  // Signed-in readers like and repost from your page. Anyone else who presses
@@ -49,6 +49,17 @@ const { base } = settings;
49
49
  <SignInForm id="atmo-dialog" returnTo={Astro.url.pathname} messages={messages} lang={lang} heading={t("dialog.heading")} />
50
50
  <p class="atmo-signin__status" role="status" data-dialog-status></p>
51
51
  </dialog>
52
+ {/* Before asking the reader's account host for one more permission: what it's for. */}
53
+ <dialog class="atmo atmo-signin atmo-permission" aria-labelledby="atmo-permission-heading" data-permission-dialog>
54
+ <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
55
+ <p class="atmo-permission__heading" id="atmo-permission-heading">{t("permission.heading")}</p>
56
+ <p data-permission-text></p>
57
+ <p class="atmo__hint">{t("permission.approve")}</p>
58
+ <div class="atmo__row">
59
+ <button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
60
+ <button type="button" class="atmo__link-button" data-close>{t("comment.cancel")}</button>
61
+ </div>
62
+ </dialog>
52
63
  {/* Reply or Quote, for signed-in readers: the comment box in an overlay. */}
53
64
  <dialog class="atmo atmo-signin atmo-compose" aria-label={t("postActions.replyLabel")} data-compose-dialog>
54
65
  <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
@@ -65,6 +76,8 @@ const { base } = settings;
65
76
  import { translate } from "../lib/translate";
66
77
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
67
78
  import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
79
+ import { following, handleSignInForm, me as whoIsSignedIn, send as act, signIn, signOut as endSignIn, state } from "../lib/client/account";
80
+ import type { Permission } from "../lib/permissions";
68
81
 
69
82
  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
83
  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 +96,6 @@ const { base } = settings;
83
96
  // Error codes from the routes, and their wording (as in the comments).
84
97
  const MESSAGE_KEYS: Record<string, string> = {
85
98
  "signed-out": "messages.signedOut",
86
- "new-permissions": "messages.newPermissions",
87
99
  "not-allowed": "messages.notAllowed",
88
100
  "too-long": "messages.tooLong",
89
101
  "too-many": "messages.tooMany",
@@ -101,11 +113,30 @@ const { base } = settings;
101
113
  const status = dialog.querySelector<HTMLElement>("[data-dialog-status]")!;
102
114
  let signedIn = false;
103
115
  let handle = "";
104
- let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
116
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
117
+ const can = (permission: Permission) => !!me.can?.includes(permission);
118
+ // One more permission from the reader's account host, then back to
119
+ // `hash` on this page to finish what they pressed. An overlay says
120
+ // what it's for first: the host may ask them to sign in again before
121
+ // it shows what's asked for, which is puzzling without warning.
122
+ const permissionDialog = this.querySelector<HTMLDialogElement>("[data-permission-dialog]")!;
123
+ let pendingPermission: { permission: Permission; hash: string } | null = null;
124
+ const askPermission = (permission: Permission, hash: string, vars: Record<string, string> = {}) => {
125
+ pendingPermission = { permission, hash };
126
+ permissionDialog.querySelector("[data-permission-text]")!.textContent = t(`permission.${permission}`, vars);
127
+ if (!permissionDialog.open) permissionDialog.showModal();
128
+ permissionDialog.querySelector<HTMLButtonElement>("[data-permission-continue]")!.focus();
129
+ };
130
+ permissionDialog.querySelector("[data-permission-continue]")!.addEventListener("click", () => {
131
+ if (!pendingPermission) return;
132
+ const { permission, hash } = pendingPermission;
133
+ void signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
134
+ });
105
135
  // What the reader has liked or reposted, by post.
106
136
  const known: Record<string, { liked: boolean; reposted: boolean }> = {};
107
137
 
108
138
  enhanceHandleBox(form, base);
139
+ handleSignInForm(form, lang);
109
140
  for (const overlay of this.querySelectorAll<HTMLDialogElement>("dialog")) {
110
141
  overlay.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", () => overlay.close()));
111
142
  // A click on the dimmed page around the overlay closes it.
@@ -150,7 +181,11 @@ const { base } = settings;
150
181
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
151
182
  },
152
183
  onError: (code, tell) => {
153
- if (code === "signed-out" || code === "new-permissions") signedIn = false;
184
+ if (code === "signed-out") signedIn = false;
185
+ if (code === "needs-permission") {
186
+ compose.close();
187
+ return void askPermission("post", `#atmo-compose=${composeMode}:${encodeURIComponent(composeRoot)}`);
188
+ }
154
189
  tell(message(code));
155
190
  },
156
191
  onInput: () => quote.update(),
@@ -206,13 +241,6 @@ const { base } = settings;
206
241
  form.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
207
242
  };
208
243
 
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
244
 
217
245
  const icon = (paths: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
218
246
 
@@ -228,7 +256,7 @@ const { base } = settings;
228
256
  button.setAttribute("aria-pressed", String(on));
229
257
  setCount(button, Math.max(0, before + (on ? 1 : -1)));
230
258
  try {
231
- const response = await send({ post: uri, kind, on });
259
+ const response = await act("react", { post: uri, kind, on });
232
260
  if (response.ok) {
233
261
  known[uri] = { ...(known[uri] ?? { liked: false, reposted: false }), [kind === "like" ? "liked" : "reposted"]: on };
234
262
  return;
@@ -236,10 +264,12 @@ const { base } = settings;
236
264
  const data = (await response.json().catch(() => ({}))) as { error?: string };
237
265
  button.setAttribute("aria-pressed", String(!on));
238
266
  setCount(button, before);
239
- if (data.error === "signed-out" || data.error === "new-permissions") {
267
+ // Signed in to comment before likes and reposts were asked for.
268
+ if (data.error === "needs-permission") return void askPermission("react", `#atmo-react=${kind}:${encodeURIComponent(uri)}`);
269
+ if (data.error === "signed-out") {
240
270
  signedIn = false;
241
271
  labelButton(button, kind);
242
- askToSignIn(kind, uri, t(data.error === "signed-out" ? "messages.signedOut" : "messages.newPermissions"));
272
+ askToSignIn(kind, uri, t("messages.signedOut"));
243
273
  }
244
274
  } catch {
245
275
  button.setAttribute("aria-pressed", String(!on));
@@ -293,6 +323,8 @@ const { base } = settings;
293
323
  link.addEventListener("click", (event) => {
294
324
  if (!signedIn || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
295
325
  event.preventDefault();
326
+ // A sign-in without posting (from an older version): asked for first.
327
+ if (!can("post")) return void askPermission("post", `#atmo-compose=${mode}:${encodeURIComponent(post.dataset.atmospherePost!)}`);
296
328
  openCompose(post, mode);
297
329
  });
298
330
  return link;
@@ -323,8 +355,7 @@ const { base } = settings;
323
355
  const uris = posts().map((post) => post.dataset.atmospherePost!);
324
356
  if (!signedIn || !uris.length) return;
325
357
  try {
326
- const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
327
- const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
358
+ const response = await state(uris);
328
359
  if (!response.ok) return;
329
360
  const state = ((await response.json()) as { posts?: typeof known }).posts ?? {};
330
361
  Object.assign(known, state);
@@ -345,17 +376,8 @@ const { base } = settings;
345
376
  const followButtons = new Map<string, { following: boolean; set: (on: boolean) => void; press: () => Promise<void> }>();
346
377
  // Following isn't part of signing in: the first follow asks the
347
378
  // 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
- };
379
+ const askFollowPermission = (subject: string, who: string) =>
380
+ askPermission("follow", `#atmo-follow=${encodeURIComponent(subject)}`, { handle: who });
359
381
  const setUpFollow = async () => {
360
382
  for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
361
383
  const subject = link.dataset.atmosphereFollow!;
@@ -365,19 +387,19 @@ const { base } = settings;
365
387
  const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
366
388
  const button = make("button", "atmo-follow");
367
389
  button.type = "button";
368
- let following = false;
390
+ let isFollowing = false;
369
391
  // The account's avatar, through the site (its own accounts' are always served).
370
392
  const avatar = make("img", "atmo-follow__avatar");
371
393
  avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
372
394
  avatar.alt = "";
373
395
  avatar.onerror = () => avatar.remove();
374
396
  const render = () => {
375
- button.setAttribute("aria-pressed", String(following));
397
+ button.setAttribute("aria-pressed", String(isFollowing));
376
398
  // The butterfly, then "Follow" with the account's avatar beside its
377
399
  // handle (wherever the language puts the handle), or the avatar
378
400
  // with "Following".
379
401
  button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${BUTTERFLY}</svg>`;
380
- if (following) {
402
+ if (isFollowing) {
381
403
  button.append(avatar, make("span", "", `✓ ${t("follow.following")}`));
382
404
  } else {
383
405
  const [before, after = ""] = t("follow.follow").split("{{handle}}");
@@ -385,44 +407,35 @@ const { base } = settings;
385
407
  button.append(avatar, make("span", "", who));
386
408
  if (after.trim()) button.append(make("span", "", after.trim()));
387
409
  }
388
- button.title = following ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
410
+ button.title = isFollowing ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
389
411
  button.setAttribute("aria-label", button.title);
390
412
  };
391
413
  const set = (on: boolean) => {
392
- following = on;
414
+ isFollowing = on;
393
415
  render();
394
416
  };
395
417
  const press = async () => {
396
418
  button.disabled = true;
397
419
  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
- });
420
+ const response = await act("follow", { subject, on: !isFollowing });
404
421
  const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
405
422
  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();
423
+ if (data.error === "needs-permission") return void askFollowPermission(subject, who);
424
+ if (data.error === "signed-out") location.reload();
408
425
  } finally {
409
426
  button.disabled = false;
410
427
  }
411
428
  };
412
429
  button.addEventListener("click", () => void press());
413
- followButtons.set(subject, { get following() { return following; }, set, press });
430
+ followButtons.set(subject, { get following() { return isFollowing; }, set, press });
414
431
  render();
415
432
  // The button takes the link's place. (Inline, as a site's own style
416
433
  // for the link could otherwise keep it showing.)
417
434
  link.hidden = true;
418
435
  link.style.display = "none";
419
436
  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
- }
437
+ const known = await following(subject);
438
+ if (known !== null) set(known);
426
439
  }
427
440
  };
428
441
 
@@ -464,6 +477,18 @@ const { base } = settings;
464
477
  if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
465
478
  };
466
479
 
480
+ // Back from allowing posting: open the Reply or Quote box they pressed.
481
+ const finishPendingCompose = () => {
482
+ const pending = location.hash.match(/^#atmo-compose=(reply|quote):(.+)$/);
483
+ if (!pending) return;
484
+ const url = new URL(location.href);
485
+ if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
486
+ history.replaceState(history.state, "", `${url.pathname}${url.search}`);
487
+ const uri = decodeURIComponent(pending[2]);
488
+ const post = posts().find((candidate) => candidate.dataset.atmospherePost === uri);
489
+ if (post && signedIn && can("post")) openCompose(post, pending[1] as "reply" | "quote");
490
+ };
491
+
467
492
  // The reader's avatar wherever the page marks data-atmosphere-account,
468
493
  // opening a menu with who they're signed in as and Sign out.
469
494
  const showAccount = (me: { handle?: string; displayName?: string; avatar?: string }) => {
@@ -492,7 +517,7 @@ const { base } = settings;
492
517
  signOut.type = "button";
493
518
  signOut.addEventListener("click", async () => {
494
519
  signOut.disabled = true;
495
- await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
520
+ await endSignIn();
496
521
  location.reload();
497
522
  });
498
523
  menu.append(who, signOut);
@@ -521,9 +546,8 @@ const { base } = settings;
521
546
  }
522
547
  };
523
548
 
524
- fetch(`${base}/me`, { credentials: "same-origin" })
525
- .then((response) => response.json())
526
- .then((found: { signedIn?: boolean; handle?: string; displayName?: string; avatar?: string }) => {
549
+ whoIsSignedIn()
550
+ .then((found) => {
527
551
  me = found;
528
552
  signedIn = !!found.signedIn;
529
553
  handle = found.handle ? `@${found.handle}` : "";
@@ -542,6 +566,7 @@ const { base } = settings;
542
566
  addActions();
543
567
  await loadState();
544
568
  await finishPending();
569
+ finishPendingCompose();
545
570
  if (signedIn) {
546
571
  await setUpFollow();
547
572
  await finishPendingFollow();
@@ -574,6 +599,16 @@ const { base } = settings;
574
599
  box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
575
600
  }
576
601
 
602
+ .atmo-permission p {
603
+ margin: 0 0 10px;
604
+ }
605
+
606
+ .atmo-permission__heading {
607
+ /* Clear of the close button. */
608
+ padding-right: 32px;
609
+ font-weight: 700;
610
+ }
611
+
577
612
  .atmo-signin::backdrop {
578
613
  background: rgb(0 0 0 / 0.55);
579
614
  }
@@ -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,7 +37,7 @@ 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"
@@ -67,6 +73,7 @@ const link = (href: string, text: string) =>
67
73
  ></ul>
68
74
  </span>
69
75
  <input type="hidden" name="returnTo" value={returnTo} data-return-to />
76
+ {permissions.map((permission) => <input type="hidden" name="permission" value={permission} />)}
70
77
  <button type="submit" class="atmo__button atmo__button--sign-in">
71
78
  <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
72
79
  {t("signIn.button")}
@@ -76,6 +83,10 @@ const link = (href: string, text: string) =>
76
83
  class="atmo__hint"
77
84
  set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
78
85
  />
86
+ <p class="atmo__hint">
87
+ {t("signIn.access")}
88
+ {policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
89
+ </p>
79
90
  </form>
80
91
 
81
92
  <style>
@@ -610,3 +610,24 @@ dialog.atmo:not([open]) {
610
610
  .atmo-comment-action[data-kind="like"][aria-pressed="true"] svg {
611
611
  fill: currentColor;
612
612
  }
613
+
614
+ /* "One more permission" under posts, for readers who signed in only to like
615
+ and repost. */
616
+ .atmo__permission {
617
+ display: grid;
618
+ gap: 8px;
619
+ max-width: 560px;
620
+ margin-top: 14px;
621
+ }
622
+
623
+ .atmo__permission p {
624
+ margin: 0;
625
+ }
626
+
627
+ /* In place of a comment whose author asks not to be shown to logged-out people. */
628
+ :where(.atmo-comment__withheld) {
629
+ margin: 0;
630
+ color: var(--atmo-muted, inherit);
631
+ font-size: 0.9em;
632
+ font-style: italic;
633
+ }
package/index.mjs CHANGED
@@ -4,44 +4,39 @@
4
4
  // replies to the post's announcement, written to the reader's own account, so
5
5
  // they can delete them there at any time.
6
6
  //
7
- // Sign-in uses AT Protocol OAuth straight to the reader's own account host:
8
- // no third-party login service is involved. This site only ever holds a
9
- // grant to create posts and to create and delete likes and reposts, plus, if
10
- // a reader allows it when they first delete a comment, to delete posts (see
11
- // SCOPE and DELETE_SCOPE in lib/oauth.ts).
7
+ // Sign-in uses AT Protocol OAuth straight to the reader's own account host,
8
+ // as a browser client: the sign-in is kept only in the reader's browser, and
9
+ // the site's server never sees it, so the server can't act for anyone.
10
+ // Signing out cancels it at the reader's host. See lib/client/account.ts.
12
11
  //
13
12
  // It adds /oauth-client-metadata.json at the site's root (what account hosts
14
- // read to know the site), and these routes under `base` (default /atmosphere):
15
- // jwks.json the public half of the site's signing key
16
- // login (POST) starts sign-in with the reader's handle
17
- // callback where the reader's host sends them back
18
- // logout (POST) signs out and revokes the site's grant
19
- // me who's signed in (for the comment box)
20
- // comment (POST) posts a comment (or quote post) as the reader
21
- // state whether the reader has liked or reposted
22
- // react (POST) likes or reposts, or undoes that
23
- // post the announcement, for the Quote preview
24
- // media avatars and link-card pictures, via this site
25
- // typeahead @mention suggestions as the reader types
26
- // thread the comments, likes and reposts to show
27
- // and a component, AtmosphereComments.astro, to put under each post.
13
+ // read to know the site), and these routes under `base` (default /atmosphere),
14
+ // none of which act for a reader:
15
+ // callback the page readers come back to after approving
16
+ // identity looks up an account for signing in
17
+ // profile a reader's public profile, for the comment box
18
+ // richtext links, @mentions and #hashtags in a comment, before it's posted
19
+ // following whether an account follows one of the site's accounts
20
+ // post the announcement, for the Quote preview
21
+ // media avatars and link-card pictures, via this site
22
+ // typeahead account suggestions as the reader types
23
+ // thread the comments, likes and reposts to show
24
+ // and two components: AtmosphereComments.astro under each post, and
25
+ // AtmosphereReactions.astro once in the layout.
28
26
  //
29
27
  // It needs an on-demand (server) adapter such as @astrojs/node.
30
28
  import { fileURLToPath } from "node:url";
31
29
 
32
30
  const VIRTUAL_ID = "virtual:atmosphere-comments/settings";
31
+ // The settings that are safe for readers' browsers (lib/public-settings.ts).
32
+ const PUBLIC_ID = "virtual:atmosphere-comments/public-settings";
33
33
 
34
34
  const ROUTES = {
35
- "jwks.json": "jwks.json.ts",
36
- login: "login.ts",
37
- callback: "callback.ts",
38
- logout: "logout.ts",
39
- me: "me.ts",
40
- comment: "comment.ts",
41
- state: "state.ts",
42
- react: "react.ts",
43
- delete: "delete.ts",
44
- follow: "follow.ts",
35
+ callback: "callback.astro",
36
+ identity: "identity.ts",
37
+ profile: "profile.ts",
38
+ richtext: "richtext.ts",
39
+ following: "following.ts",
45
40
  post: "post.ts",
46
41
  media: "media.ts",
47
42
  typeahead: "typeahead.ts",
@@ -102,14 +97,29 @@ export default function atmosphereComments(options) {
102
97
  });
103
98
  }
104
99
 
105
- // The routes and component read the settings from a virtual module.
100
+ const publicSettings = {
101
+ clientName: settings.clientName,
102
+ allowedAuthors,
103
+ base,
104
+ site: settings.site,
105
+ logoUri: settings.logoUri,
106
+ policyUri: settings.policyUri,
107
+ tosUri: settings.tosUri,
108
+ };
109
+
110
+ // The routes and components read the settings from virtual modules.
106
111
  updateConfig({
107
112
  vite: {
108
113
  plugins: [
109
114
  {
110
115
  name: "atmosphere-comments-settings",
111
- resolveId: (id) => (id === VIRTUAL_ID ? `\0${VIRTUAL_ID}` : undefined),
112
- load: (id) => (id === `\0${VIRTUAL_ID}` ? `export default ${JSON.stringify(settings)};` : undefined),
116
+ resolveId: (id) => (id === VIRTUAL_ID || id === PUBLIC_ID ? `\0${id}` : undefined),
117
+ load: (id) =>
118
+ id === `\0${VIRTUAL_ID}`
119
+ ? `export default ${JSON.stringify(settings)};`
120
+ : id === `\0${PUBLIC_ID}`
121
+ ? `export default ${JSON.stringify(publicSettings)};`
122
+ : undefined,
113
123
  },
114
124
  ],
115
125
  },