astro-atmosphere-comments 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,14 @@
18
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,6 +56,14 @@ 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>
52
69
  {/* Before asking the reader's account host for one more permission: what it's for. */}
@@ -77,6 +94,7 @@ const { base } = settings;
77
94
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
78
95
  import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
79
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";
80
98
  import type { Permission } from "../lib/permissions";
81
99
 
82
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" />';
@@ -232,14 +250,28 @@ const { base } = settings;
232
250
  return element;
233
251
  };
234
252
 
235
- // Not signed in: the sign-in form, coming back to finish this like or repost.
236
- const askToSignIn = (kind: Kind, uri: string, problem = "") => {
237
- form.querySelector<HTMLInputElement>("[data-return-to]")!.value =
238
- `${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}`;
239
261
  status.textContent = problem;
262
+ dialogElsewhere.hidden = !blueskyUrl;
263
+ if (blueskyUrl) dialogElsewhere.querySelector("a")!.href = blueskyUrl;
240
264
  if (!dialog.open) dialog.showModal();
241
265
  form.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
242
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));
243
275
 
244
276
 
245
277
  const icon = (paths: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
@@ -308,7 +340,8 @@ const { base } = settings;
308
340
  };
309
341
 
310
342
  // Reply and Quote: the comment box in an overlay for signed-in readers,
311
- // 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.
312
345
  // Icons only, as on Bluesky, with their names for screen readers and as
313
346
  // tooltips.
314
347
  const bskyLink = (post: HTMLElement, paths: string) => {
@@ -321,8 +354,9 @@ const { base } = settings;
321
354
  labelLink(link);
322
355
  link.innerHTML = icon(paths);
323
356
  link.addEventListener("click", (event) => {
324
- 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;
325
358
  event.preventDefault();
359
+ if (!signedIn) return askToSignInToCompose(mode, post.dataset.atmospherePost!);
326
360
  // A sign-in without posting (from an older version): asked for first.
327
361
  if (!can("post")) return void askPermission("post", `#atmo-compose=${mode}:${encodeURIComponent(post.dataset.atmospherePost!)}`);
328
362
  openCompose(post, mode);
@@ -439,6 +473,14 @@ const { base } = settings;
439
473
  }
440
474
  };
441
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
+
442
484
  // Back from approving following: follow as they asked.
443
485
  const finishPendingFollow = async () => {
444
486
  const pending = location.hash.match(/^#atmo-follow=(.+)$/);
@@ -477,16 +519,28 @@ const { base } = settings;
477
519
  if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
478
520
  };
479
521
 
480
- // Back from allowing posting: open the Reply or Quote box they pressed.
481
- const finishPendingCompose = () => {
522
+ // Back from signing in or allowing posting: open the Reply or Quote box they pressed.
523
+ const finishPendingCompose = async () => {
482
524
  const pending = location.hash.match(/^#atmo-compose=(reply|quote):(.+)$/);
483
525
  if (!pending) return;
484
526
  const url = new URL(location.href);
527
+ const problem = url.searchParams.get("atmosphere") ?? "";
485
528
  if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
486
- history.replaceState(history.state, "", `${url.pathname}${url.search}`);
487
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}`);
488
536
  const post = posts().find((candidate) => candidate.dataset.atmospherePost === uri);
489
- if (post && signedIn && can("post")) openCompose(post, pending[1] as "reply" | "quote");
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
+ }
490
544
  };
491
545
 
492
546
  // The reader's avatar wherever the page marks data-atmosphere-account,
@@ -566,11 +620,13 @@ const { base } = settings;
566
620
  addActions();
567
621
  await loadState();
568
622
  await finishPending();
569
- finishPendingCompose();
623
+ await finishPendingCompose();
570
624
  if (signedIn) {
571
625
  await setUpFollow();
572
626
  await finishPendingFollow();
627
+ await loadSubscribed();
573
628
  }
629
+ await finishPendingSubscribe(t, askToSubscribe);
574
630
  });
575
631
 
576
632
  // Posts that arrive later (a server island, say) get buttons too.
@@ -587,18 +643,6 @@ const { base } = settings;
587
643
  </script>
588
644
 
589
645
  <style>
590
- .atmo-signin {
591
- /* Centred on the screen, whatever margin a site gives .atmo. */
592
- margin: auto;
593
- width: min(92vw, 520px);
594
- padding: 22px 22px 18px;
595
- border: 1px solid var(--atmo-border, #8886);
596
- border-radius: calc(var(--atmo-radius, 10px) + 6px);
597
- background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
598
- color: var(--atmo-text, CanvasText);
599
- box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
600
- }
601
-
602
646
  .atmo-permission p {
603
647
  margin: 0 0 10px;
604
648
  }
@@ -609,28 +653,6 @@ const { base } = settings;
609
653
  font-weight: 700;
610
654
  }
611
655
 
612
- .atmo-signin::backdrop {
613
- background: rgb(0 0 0 / 0.55);
614
- }
615
-
616
- .atmo-signin :global(.atmo__login) {
617
- margin-top: 0;
618
- }
619
-
620
- .atmo-signin__close {
621
- position: absolute;
622
- top: 8px;
623
- right: 10px;
624
- padding: 4px 8px;
625
- border: 0;
626
- background: none;
627
- color: var(--atmo-muted, inherit);
628
- font: inherit;
629
- font-size: 1.4em;
630
- line-height: 1;
631
- cursor: pointer;
632
- }
633
-
634
656
  .atmo-compose {
635
657
  width: min(92vw, 600px);
636
658
  }
@@ -643,16 +665,6 @@ const { base } = settings;
643
665
  margin: 0 0 12px;
644
666
  }
645
667
 
646
- .atmo-signin__status {
647
- margin: 10px 0 0;
648
- color: var(--atmo-muted, inherit);
649
- font-size: 0.9em;
650
- }
651
-
652
- .atmo-signin__status:empty {
653
- display: none;
654
- }
655
-
656
668
  /* The row under each post. Defaults carry no weight, so a site's own
657
669
  rules always win. */
658
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>
@@ -44,6 +44,7 @@ const link = (href: string, text: string) =>
44
44
  set:html={th("signIn.anyAccount", {
45
45
  atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
46
46
  bluesky: link("https://bsky.app", "Bluesky"),
47
+ createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")),
47
48
  })}
48
49
  />
49
50
  <div class="atmo__row">
@@ -76,17 +77,19 @@ const link = (href: string, text: string) =>
76
77
  {permissions.map((permission) => <input type="hidden" name="permission" value={permission} />)}
77
78
  <button type="submit" class="atmo__button atmo__button--sign-in">
78
79
  <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
80
+ <span class="atmo__spinner" aria-hidden="true"></span>
79
81
  {t("signIn.button")}
80
82
  </button>
81
83
  </div>
82
- <p
83
- class="atmo__hint"
84
- set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
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>
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>
90
93
  </form>
91
94
 
92
95
  <style>
@@ -105,6 +108,17 @@ const link = (href: string, text: string) =>
105
108
  font-weight: 700;
106
109
  }
107
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
+
108
122
  .atmo__hint {
109
123
  margin: 0;
110
124
  color: var(--atmo-muted, inherit);
@@ -165,6 +179,39 @@ const link = (href: string, text: string) =>
165
179
  cursor: pointer;
166
180
  }
167
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
+
168
215
  .atmo__button .atmo__logo {
169
216
  flex: none;
170
217
  width: 20px;
@@ -118,6 +118,222 @@ dialog.atmo:not([open]) {
118
118
  gap: 8px;
119
119
  }
120
120
 
121
+ /* The sign-in overlay, shared by both components. */
122
+ dialog.atmo.atmo-signin {
123
+ /* Centred on the screen, whatever margin a site gives .atmo. */
124
+ margin: auto;
125
+ width: min(92vw, 520px);
126
+ padding: 22px 22px 18px;
127
+ border: 1px solid var(--atmo-border, #8886);
128
+ border-radius: calc(var(--atmo-radius, 10px) + 6px);
129
+ background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
130
+ color: var(--atmo-text, CanvasText);
131
+ box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
132
+ }
133
+
134
+ /* The sign-in form is short, and its account suggestions can reach past the
135
+ overlay's edge. (The comment box's overlay can be tall, so it keeps scrolling.) */
136
+ dialog.atmo.atmo-signin:has(> .atmo__login) {
137
+ overflow: visible;
138
+ }
139
+
140
+ /* As wide as the form, so names and handles fit on one line. */
141
+ .atmo-signin .atmo__suggestions {
142
+ min-width: min(474px, calc(92vw - 46px));
143
+ }
144
+
145
+ .atmo-signin::backdrop {
146
+ background: rgb(0 0 0 / 0.55);
147
+ }
148
+
149
+ .atmo-signin .atmo__login {
150
+ margin-top: 0;
151
+ }
152
+
153
+ .atmo-signin__close {
154
+ position: absolute;
155
+ top: 8px;
156
+ right: 10px;
157
+ padding: 4px 8px;
158
+ border: 0;
159
+ background: none;
160
+ color: var(--atmo-muted, inherit);
161
+ font: inherit;
162
+ font-size: 1.4em;
163
+ line-height: 1;
164
+ cursor: pointer;
165
+ }
166
+
167
+ .atmo-signin__status {
168
+ margin: 10px 0 0;
169
+ color: var(--atmo-muted, inherit);
170
+ font-size: 0.9em;
171
+ }
172
+
173
+ .atmo-signin__status:empty {
174
+ display: none;
175
+ }
176
+
177
+ /* Signed out: looks like the comment box, and opens the sign-in overlay. */
178
+ .atmo__prompt-box {
179
+ display: grid;
180
+ gap: 8px;
181
+ max-width: 560px;
182
+ margin-top: 14px;
183
+ }
184
+
185
+ .atmo__prompt-box[hidden] {
186
+ display: none;
187
+ }
188
+
189
+ .atmo__prompt-box .atmo__button:disabled {
190
+ cursor: not-allowed;
191
+ }
192
+
193
+ .atmo__prompt {
194
+ /* A button centres its text; a text box starts at the top left. */
195
+ display: flex;
196
+ align-items: flex-start;
197
+ width: 100%;
198
+ min-height: 84px;
199
+ padding: 12px 14px;
200
+ border: 1px solid var(--atmo-border, #8886);
201
+ border-radius: var(--atmo-radius, 10px);
202
+ background: var(--atmo-field-bg, transparent);
203
+ color: var(--atmo-muted, inherit);
204
+ font: inherit;
205
+ text-align: left;
206
+ vertical-align: top;
207
+ cursor: text;
208
+ }
209
+
210
+ .atmo__prompt:hover,
211
+ .atmo__prompt:focus-visible {
212
+ border-color: var(--atmo-accent, #1185fe);
213
+ }
214
+
215
+ /* Subscribe and its menu: a split button, with Like and Repost once signed
216
+ in, or on its own above the sign-in form. */
217
+ .atmo-subscribe {
218
+ position: relative;
219
+ display: inline-flex;
220
+ }
221
+
222
+ .atmo-subscribe[hidden] {
223
+ display: none;
224
+ }
225
+
226
+ .atmo-subscribe .atmo-subscribe__button {
227
+ border-radius: 999px 0 0 999px;
228
+ }
229
+
230
+ .atmo-subscribe__button[hidden] {
231
+ display: none;
232
+ }
233
+
234
+ .atmo-subscribe__button:disabled {
235
+ opacity: 0.6;
236
+ cursor: progress;
237
+ }
238
+
239
+ .atmo-subscribe__logo {
240
+ flex: none;
241
+ width: 18px;
242
+ height: 18px;
243
+ background-color: currentColor;
244
+ mask: var(--atmo-logo) center / contain no-repeat;
245
+ }
246
+
247
+ .atmo-subscribe .atmo-subscribe__more {
248
+ padding: 0 10px 0 8px;
249
+ border-left: 0;
250
+ border-radius: 0 999px 999px 0;
251
+ }
252
+
253
+ /* Subscribed: both halves look like one button. */
254
+ .atmo-subscribe:has([aria-pressed="true"]) .atmo-subscribe__more {
255
+ border-color: color-mix(in srgb, var(--atmo-accent, #1185fe) 40%, transparent);
256
+ background: color-mix(in srgb, var(--atmo-accent, #1185fe) 8%, transparent);
257
+ color: var(--atmo-accent, #1185fe);
258
+ }
259
+
260
+ .atmo-subscribe .atmo-subscribe__more svg {
261
+ width: 15px;
262
+ height: 15px;
263
+ }
264
+
265
+ .atmo-subscribe__menu {
266
+ position: absolute;
267
+ z-index: 20;
268
+ top: calc(100% + 6px);
269
+ left: 0;
270
+ display: grid;
271
+ width: min(260px, 80vw);
272
+ padding: 6px;
273
+ border: 1px solid var(--atmo-border, #8886);
274
+ border-radius: var(--atmo-radius, 10px);
275
+ background: var(--atmo-menu-bg, Canvas);
276
+ box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
277
+ }
278
+
279
+ .atmo-subscribe__menu[hidden] {
280
+ display: none;
281
+ }
282
+
283
+ .atmo-subscribe__note {
284
+ margin: 0 0 6px;
285
+ padding: 6px 10px 10px;
286
+ border-bottom: 1px solid var(--atmo-border, #8886);
287
+ color: var(--atmo-muted, inherit);
288
+ font-size: 0.85em;
289
+ }
290
+
291
+ .atmo-subscribe__menu > a[hidden] {
292
+ display: none;
293
+ }
294
+
295
+ .atmo-subscribe__menu svg {
296
+ flex: none;
297
+ width: 16px;
298
+ height: 16px;
299
+ fill: none;
300
+ stroke: currentColor;
301
+ stroke-width: 1.8;
302
+ stroke-linecap: round;
303
+ stroke-linejoin: round;
304
+ }
305
+
306
+ .atmo-subscribe__menu svg circle,
307
+ .atmo-subscribe__menu svg.atmo-subscribe__filled {
308
+ fill: currentColor;
309
+ stroke: none;
310
+ }
311
+
312
+ .atmo-subscribe__menu > a,
313
+ .atmo-subscribe__menu > button {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 9px;
317
+ padding: 7px 10px;
318
+ border: 0;
319
+ border-radius: calc(var(--atmo-radius, 10px) - 4px);
320
+ background: none;
321
+ color: var(--atmo-text, inherit);
322
+ font: inherit;
323
+ font-size: 0.9em;
324
+ text-align: left;
325
+ text-decoration: none;
326
+ cursor: pointer;
327
+ }
328
+
329
+ .atmo-subscribe__menu > a:hover,
330
+ .atmo-subscribe__menu > a:focus-visible,
331
+ .atmo-subscribe__menu > button:hover,
332
+ .atmo-subscribe__menu > button:focus-visible {
333
+ background: var(--atmo-field-bg, #8882);
334
+ color: var(--atmo-accent, #1185fe);
335
+ }
336
+
121
337
  .atmo__action {
122
338
  display: inline-flex;
123
339
  align-items: center;
@@ -149,7 +365,9 @@ dialog.atmo:not([open]) {
149
365
  }
150
366
 
151
367
  .atmo__action[aria-pressed="true"] {
152
- border-color: var(--atmo-accent, #1185fe);
368
+ /* Softer than the text, like the outlines of the buttons beside it. */
369
+ border-color: color-mix(in srgb, var(--atmo-accent, #1185fe) 40%, transparent);
370
+ background: color-mix(in srgb, var(--atmo-accent, #1185fe) 8%, transparent);
153
371
  color: var(--atmo-accent, #1185fe);
154
372
  }
155
373
 
@@ -358,6 +576,10 @@ dialog.atmo:not([open]) {
358
576
  overflow-wrap: anywhere;
359
577
  }
360
578
 
579
+ .atmo__preview-text:empty {
580
+ display: none;
581
+ }
582
+
361
583
  .atmo__preview-text.is-placeholder {
362
584
  color: var(--atmo-muted, inherit);
363
585
  font-style: italic;
@@ -386,6 +608,13 @@ dialog.atmo:not([open]) {
386
608
  padding: 10px 12px;
387
609
  }
388
610
 
611
+ .atmo__card [data-card-title] {
612
+ display: -webkit-box;
613
+ overflow: hidden;
614
+ -webkit-box-orient: vertical;
615
+ -webkit-line-clamp: 2;
616
+ }
617
+
389
618
  .atmo__card [data-card-description] {
390
619
  display: -webkit-box;
391
620
  overflow: hidden;
@@ -393,10 +622,25 @@ dialog.atmo:not([open]) {
393
622
  -webkit-line-clamp: 2;
394
623
  }
395
624
 
396
- .atmo__card-domain {
625
+ .atmo__card .atmo__card-domain {
626
+ display: flex;
627
+ align-items: center;
628
+ gap: 4px;
629
+ margin-top: 6px;
630
+ padding-top: 6px;
631
+ border-top: 1px solid var(--atmo-border, #8886);
397
632
  font-size: 0.85em;
398
633
  }
399
634
 
635
+ .atmo__card-domain svg {
636
+ flex: none;
637
+ width: 12px;
638
+ height: 12px;
639
+ fill: none;
640
+ stroke: currentColor;
641
+ stroke-width: 1.8;
642
+ }
643
+
400
644
  /* The likes and reposts, and the comments. These are drawn by the script,
401
645
  and wrapped in :where() so they carry no weight: any rule a site writes
402
646
  for these classes wins. */
package/index.d.ts CHANGED
@@ -16,6 +16,17 @@ export type AtmosphereCommentsOptions = {
16
16
  /** Your privacy policy and terms, if you have them (absolute addresses). */
17
17
  policyUri?: string;
18
18
  tosUri?: string;
19
+ /**
20
+ * Your Standard.site publication, if you publish one. Turns on the Subscribe
21
+ * button, and makes Like under a post also recommend its Standard.site
22
+ * record. Readers are asked for this in the same approval as everything else.
23
+ */
24
+ standardSite?: {
25
+ /** The publication record's AT URI: at://did:…/site.standard.publication/… */
26
+ publication: string;
27
+ /** Your feed's address, for the menu beside Subscribe, e.g. "/feed/". */
28
+ feed?: string;
29
+ };
19
30
  /**
20
31
  * Comments never to show: bsky.app post or profile addresses, AT URIs,
21
32
  * handles or DIDs (on top of replies hidden on Bluesky and moderation labels).
package/index.mjs CHANGED
@@ -44,6 +44,7 @@ const ROUTES = {
44
44
  };
45
45
 
46
46
  const DID = /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/;
47
+ const PUBLICATION = /^at:\/\/did:[a-z0-9]+:[a-zA-Z0-9._:%-]+\/site\.standard\.publication\/[a-zA-Z0-9._~-]+$/;
47
48
 
48
49
  /**
49
50
  * @param {import("./index").AtmosphereCommentsOptions} options
@@ -57,6 +58,10 @@ export default function atmosphereComments(options) {
57
58
  "atmosphere-comments: `allowedAuthors` must list the DIDs (did:plc:… or did:web:…) of the accounts that post your announcements.",
58
59
  );
59
60
  }
61
+ const standardSite = options.standardSite;
62
+ if (standardSite && !PUBLICATION.test(standardSite.publication ?? "")) {
63
+ throw new Error("atmosphere-comments: `standardSite.publication` must be your publication's AT URI (at://did:…/site.standard.publication/…).");
64
+ }
60
65
  const base = `/${(options.base ?? "/atmosphere").replace(/^\/+|\/+$/g, "")}`;
61
66
  return {
62
67
  name: "atmosphere-comments",
@@ -75,6 +80,7 @@ export default function atmosphereComments(options) {
75
80
  logoUri: options.logoUri,
76
81
  policyUri: options.policyUri,
77
82
  tosUri: options.tosUri,
83
+ standardSite,
78
84
  hidden: options.hidden ?? [],
79
85
  locale: options.locale,
80
86
  messages: options.messages ?? {},
@@ -105,11 +111,16 @@ export default function atmosphereComments(options) {
105
111
  logoUri: settings.logoUri,
106
112
  policyUri: settings.policyUri,
107
113
  tosUri: settings.tosUri,
114
+ standardSite,
108
115
  };
109
116
 
110
117
  // The routes and components read the settings from virtual modules.
111
118
  updateConfig({
112
119
  vite: {
120
+ // Readers' browsers load the sign-in library only when needed, so
121
+ // Vite's dev server wouldn't find it to bundle it in advance, and
122
+ // the old-style modules it depends on then fail in the browser.
123
+ optimizeDeps: { include: ["astro-atmosphere-comments > @atproto/oauth-client-browser"] },
113
124
  plugins: [
114
125
  {
115
126
  name: "atmosphere-comments-settings",