astro-atmosphere-comments 0.6.1 → 0.8.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.
@@ -92,7 +92,8 @@ const { base } = settings;
92
92
  <script>
93
93
  import { translate } from "../lib/translate";
94
94
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
95
- import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
95
+ import { MAX, fetchPost, quotePreview, setUpComposer, showPost, type Posted, type ShownPost } from "../lib/client/composer";
96
+ import { showDone } from "../lib/client/done";
96
97
  import { following, handleSignInForm, me as whoIsSignedIn, send as act, signIn, signOut as endSignIn, state } from "../lib/client/account";
97
98
  import { addSubscribeButtons, finishPendingSubscribe, loadSubscribed } from "../lib/client/subscribe";
98
99
  import type { Permission } from "../lib/permissions";
@@ -211,13 +212,45 @@ const { base } = settings;
211
212
  const quote = quotePreview(composeForm, composer.textarea, composerContext);
212
213
  $c<HTMLElement>("[data-cancel-context]").hidden = true;
213
214
 
215
+ // The post being replied to, shown above the box, since the reply won't
216
+ // appear on this page: fetched through the site once per post.
217
+ const parent = $c<HTMLElement>("[data-parent]");
218
+ const fetched = new Map<string, Promise<ShownPost | null>>();
219
+ const postFor = (uri: string) => {
220
+ if (!fetched.has(uri)) fetched.set(uri, fetchPost(base, uri));
221
+ return fetched.get(uri)!;
222
+ };
223
+ // A handle, never a DID: a post's address may name its author either way
224
+ // (Bluesky accepts both), and a lookup that fails gives back the DID.
225
+ const shownHandle = (handle?: string | null) =>
226
+ handle && !handle.startsWith("did:") && handle !== "handle.invalid" ? handle : null;
227
+ const replyingTo = (handle?: string | null) =>
228
+ t("composer.replyingTo", { name: shownHandle(handle) ? `@${handle}` : t("comment.someone") });
229
+
214
230
  const openCompose = (post: HTMLElement, mode: "reply" | "quote") => {
215
231
  composeRoot = post.dataset.atmospherePost!;
216
232
  composeMode = mode;
217
- const author = post.dataset.atmosphereUrl?.match(/\/profile\/([^/]+)\//)?.[1];
233
+ // From the bsky.app address until the post itself arrives.
234
+ let author: string | undefined;
235
+ try {
236
+ author = decodeURIComponent(post.dataset.atmosphereUrl?.match(/\/profile\/([^/]+)\//)?.[1] ?? "");
237
+ } catch {
238
+ author = undefined;
239
+ }
240
+ const context = $c<HTMLElement>("[data-context-text]");
218
241
  $c<HTMLElement>("[data-context]").hidden = false;
219
- $c<HTMLElement>("[data-context-text]").textContent =
220
- mode === "quote" ? t("composer.quoting") : t("composer.replyingTo", { name: author ? `@${author}` : t("comment.someone") });
242
+ parent.hidden = true;
243
+ if (mode === "quote") context.textContent = t("composer.quoting");
244
+ else {
245
+ context.textContent = replyingTo(author);
246
+ const root = composeRoot;
247
+ void postFor(root).then((shown) => {
248
+ if (!shown || composeRoot !== root || composeMode !== "reply") return;
249
+ showPost(parent, shown, composerContext);
250
+ parent.hidden = false;
251
+ if (shownHandle(shown.author.handle)) context.textContent = replyingTo(shown.author.handle);
252
+ });
253
+ }
221
254
  $c<HTMLElement>("[data-verb]").textContent = t(mode === "quote" ? "composer.quote" : "comment.reply");
222
255
  $c<HTMLElement>("[data-submit]").textContent = t(mode === "quote" ? "composer.postQuote" : "composer.postReply");
223
256
  $c<HTMLElement>("[data-preview]").hidden = mode !== "quote";
@@ -407,7 +440,16 @@ const { base } = settings;
407
440
  // Follow buttons in place of links marked data-atmosphere-follow, for
408
441
  // signed-in readers: "Follow @handle", then "Following" (a second press
409
442
  // unfollows, as on Bluesky, without inviting it).
410
- const followButtons = new Map<string, { following: boolean; set: (on: boolean) => void; press: () => Promise<void> }>();
443
+ // One entry per account, however many links for it the page has (say,
444
+ // one in a toolbar and one in the Subscribe menu): they all agree.
445
+ type FollowState = {
446
+ following: boolean;
447
+ buttons: HTMLButtonElement[];
448
+ renders: (() => void)[];
449
+ set: (on: boolean) => void;
450
+ press: () => Promise<void>;
451
+ };
452
+ const followButtons = new Map<string, FollowState>();
411
453
  // Following isn't part of signing in: the first follow asks the
412
454
  // reader's account host for it, then carries on when they're back.
413
455
  const askFollowPermission = (subject: string, who: string) =>
@@ -415,19 +457,47 @@ const { base } = settings;
415
457
  const setUpFollow = async () => {
416
458
  for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
417
459
  const subject = link.dataset.atmosphereFollow!;
418
- if (followButtons.has(subject) || link.nextElementSibling?.matches(".atmo-follow")) continue;
460
+ if (link.nextElementSibling?.matches(".atmo-follow")) continue;
419
461
  // Nobody can follow themselves: the account's owner keeps the link.
420
462
  if (subject === me.did) continue;
421
463
  const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
464
+ let state = followButtons.get(subject);
465
+ const isNew = !state;
466
+ if (!state) {
467
+ const created: FollowState = {
468
+ following: false,
469
+ buttons: [],
470
+ renders: [],
471
+ set: (on) => {
472
+ created.following = on;
473
+ created.renders.forEach((render) => render());
474
+ },
475
+ press: async () => {
476
+ created.buttons.forEach((each) => (each.disabled = true));
477
+ try {
478
+ const response = await act("follow", { subject, on: !created.following });
479
+ const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
480
+ if (response.ok) return created.set(!!data.following);
481
+ if (data.error === "needs-permission") return void askFollowPermission(subject, who);
482
+ if (data.error === "signed-out") location.reload();
483
+ } finally {
484
+ created.buttons.forEach((each) => (each.disabled = false));
485
+ }
486
+ },
487
+ };
488
+ state = created;
489
+ followButtons.set(subject, state);
490
+ }
422
491
  const button = make("button", "atmo-follow");
423
492
  button.type = "button";
424
- let isFollowing = false;
425
493
  // The account's avatar, through the site (its own accounts' are always served).
426
494
  const avatar = make("img", "atmo-follow__avatar");
427
495
  avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
428
496
  avatar.alt = "";
429
497
  avatar.onerror = () => avatar.remove();
498
+ const current = state;
430
499
  const render = () => {
500
+ const isFollowing = current.following;
431
501
  button.setAttribute("aria-pressed", String(isFollowing));
432
502
  // The butterfly, then "Follow" with the account's avatar beside its
433
503
  // handle (wherever the language puts the handle), or the avatar
@@ -444,32 +514,19 @@ const { base } = settings;
444
514
  button.title = isFollowing ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
445
515
  button.setAttribute("aria-label", button.title);
446
516
  };
447
- const set = (on: boolean) => {
448
- isFollowing = on;
449
- render();
450
- };
451
- const press = async () => {
452
- button.disabled = true;
453
- try {
454
- const response = await act("follow", { subject, on: !isFollowing });
455
- const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
456
- if (response.ok) return set(!!data.following);
457
- if (data.error === "needs-permission") return void askFollowPermission(subject, who);
458
- if (data.error === "signed-out") location.reload();
459
- } finally {
460
- button.disabled = false;
461
- }
462
- };
463
- button.addEventListener("click", () => void press());
464
- followButtons.set(subject, { get following() { return isFollowing; }, set, press });
517
+ button.addEventListener("click", () => void current.press());
518
+ current.buttons.push(button);
519
+ current.renders.push(render);
465
520
  render();
466
521
  // The button takes the link's place. (Inline, as a site's own style
467
522
  // for the link could otherwise keep it showing.)
468
523
  link.hidden = true;
469
524
  link.style.display = "none";
470
525
  link.after(button);
471
- const known = await following(subject);
472
- if (known !== null) set(known);
526
+ if (isNew) {
527
+ const known = await following(subject);
528
+ if (known !== null) current.set(known);
529
+ }
473
530
  }
474
531
  };
475
532
 
@@ -516,7 +573,11 @@ const { base } = settings;
516
573
  return;
517
574
  }
518
575
  const button = post.querySelector<HTMLButtonElement>(`.atmo-post-action[data-kind='${kind}']`);
519
- if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
576
+ if (button && button.getAttribute("aria-pressed") !== "true") {
577
+ await react(post, button, kind as Kind, true);
578
+ // A brief "Liked" or "Reposted", so it's clear it went through.
579
+ if (button.getAttribute("aria-pressed") === "true") showDone(button, t(kind === "like" ? "actions.liked" : "actions.reposted"));
580
+ }
520
581
  };
521
582
 
522
583
  // Back from signing in or allowing posting: open the Reply or Quote box they pressed.
@@ -0,0 +1,135 @@
1
+ ---
2
+ // Subscribe to the site's Standard.site publication, with a ⌄ menu of more
3
+ // ways to follow it (Copy link, the feed, Bluesky, Standard Reader, Leaflet
4
+ // and any links of the site's own). Shown only with the integration's
5
+ // `standardSite` option, and only with JavaScript (the menu needs it).
6
+ //
7
+ // Placed anywhere, such as a blog's list of posts. The Subscribe button
8
+ // itself is worked by AtmosphereReactions, which offers signing in, so put
9
+ // that in the layout too. AtmosphereComments uses this component for the one
10
+ // under each post, and works that button itself.
11
+ //
12
+ // Props:
13
+ // align which edge the menu lines up with: "start" (default) or "end"
14
+ // (for a button near the right-hand edge, so the menu opens leftwards)
15
+ // copyUrl what Copy link copies (default: the site's address)
16
+ // links more menu items, after the built-in ones: [{ label, href }]
17
+ // bluesky adds "View on Bluesky", going here (under a post: its thread)
18
+ //
19
+ // The menu's note ends by suggesting following the publication's account on
20
+ // Bluesky, with AtmosphereFollow: a link to its profile, which AtmosphereReactions
21
+ // turns into a Follow / Following button for signed-in readers.
22
+ // locale, messages as for AtmosphereComments
23
+ import logo from "../assets/union.svg?url";
24
+ import settings from "../lib/settings";
25
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
26
+ import { escapeHtml } from "../lib/translate";
27
+ import { profileFor } from "../lib/profile";
28
+ import AtmosphereFollow from "./AtmosphereFollow.astro";
29
+ import "./atmosphere.css";
30
+
31
+ interface Props {
32
+ align?: "start" | "end";
33
+ copyUrl?: string;
34
+ links?: { label: string; href: string }[];
35
+ bluesky?: string;
36
+ locale?: string;
37
+ messages?: Record<string, string>;
38
+ }
39
+
40
+ const { align = "start", copyUrl = settings.site, links = [] } = Astro.props;
41
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
42
+ const messages = messagesFor(lang, { ...settings.messages, ...Astro.props.messages });
43
+ const t = (key: string) => translate(messages, lang, key);
44
+ const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
45
+ const link = (href: string, text: string) => `<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(text)}</a>`;
46
+
47
+ const publication = settings.standardSite?.publication ?? "";
48
+ const feed = settings.standardSite?.feed;
49
+ // at://<did>/site.standard.publication/<rkey>
50
+ const [, , did = "", , rkey = ""] = publication.split("/");
51
+ const bluesky = Astro.props.bluesky ?? "";
52
+ // The publication's account, by handle, for the note and the Follow link
53
+ // (looked up when the page is built; without it, the note leaves that part out).
54
+ const account = did ? await profileFor(did).catch(() => null) : null;
55
+ const accountHandle = account && account.handle !== did ? account.handle : "";
56
+ ---
57
+
58
+ {publication && (
59
+ <atmosphere-subscribe class:list={["atmo-subscribe", align === "end" && "atmo-subscribe--end"]} data-subscribe-panel data-pagefind-ignore hidden>
60
+ <button type="button" class="atmo__action atmo-subscribe__button" data-atmosphere-subscribe hidden>
61
+ <span class="atmo-subscribe__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
62
+ <span data-atmosphere-subscribe-label>{t("subscribe.subscribe")}</span>
63
+ </button>
64
+ <button type="button" class="atmo__action atmo-subscribe__more" aria-label={t("subscribe.more")} title={t("subscribe.more")} aria-expanded="false" data-subscribe-more>
65
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
66
+ </button>
67
+ <div class="atmo-subscribe__menu" data-subscribe-menu hidden>
68
+ <div class="atmo-subscribe__note">
69
+ <p
70
+ set:html={th(feed ? "subscribe.panelTextFeed" : "subscribe.panelText", {
71
+ leaflet: link("https://leaflet.pub", "Leaflet"),
72
+ standardReader: link("https://standard-reader.app", "Standard Reader"),
73
+ }) + (accountHandle ? ` ${escapeHtml(translate(messages, lang, "subscribe.followNote", { handle: `@${accountHandle}` }))}` : "")}
74
+ />
75
+ {accountHandle && <AtmosphereFollow account={did} label={t("subscribe.follow")} class="atmo-subscribe__follow" locale={lang} messages={messages} />}
76
+ </div>
77
+ <button type="button" data-subscribe-copy={copyUrl} 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>
78
+ {feed && <a href={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>}
79
+ <a href={bluesky || undefined} hidden={!bluesky} 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>
80
+ <a href={`https://standard-reader.app/p/${did}/${rkey}`} 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>
81
+ <a href={`https://leaflet.pub/lish/${did}/${rkey}`} 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.leaflet")}</span></a>
82
+ {links.map(({ label, href }) => (
83
+ <a href={href} 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>{label}</span></a>
84
+ ))}
85
+ </div>
86
+ </atmosphere-subscribe>
87
+ )}
88
+
89
+ <script>
90
+ // The ⌄ menu: opens and closes (click, a click elsewhere, Escape) and Copy
91
+ // link. Shows itself once this runs, except under a post, where
92
+ // AtmosphereComments shows it when the comments are ready.
93
+ class AtmosphereSubscribe extends HTMLElement {
94
+ connectedCallback() {
95
+ const more = this.querySelector<HTMLButtonElement>("[data-subscribe-more]");
96
+ const menu = this.querySelector<HTMLElement>("[data-subscribe-menu]");
97
+ if (!more || !menu || this.dataset.ready !== undefined) return;
98
+ this.dataset.ready = "";
99
+ if (!this.closest("atmosphere-comments")) this.hidden = false;
100
+ const setMenu = (open: boolean) => {
101
+ menu.hidden = !open;
102
+ more.setAttribute("aria-expanded", String(open));
103
+ };
104
+ more.addEventListener("click", () => {
105
+ setMenu(menu.hidden);
106
+ if (!menu.hidden) menu.querySelector<HTMLElement>("[data-subscribe-copy]")?.focus();
107
+ });
108
+ document.addEventListener("click", (event) => {
109
+ if (!this.contains(event.target as Node)) setMenu(false);
110
+ });
111
+ this.addEventListener("keydown", (event) => {
112
+ if (event.key === "Escape" && !menu.hidden) {
113
+ setMenu(false);
114
+ more.focus();
115
+ }
116
+ });
117
+ const copy = menu.querySelector<HTMLButtonElement>("[data-subscribe-copy]")!;
118
+ const copyText = copy.querySelector<HTMLElement>("[data-copy-label]")!;
119
+ const copyLabel = copyText.textContent;
120
+ copy.addEventListener("click", async () => {
121
+ try {
122
+ await navigator.clipboard.writeText(copy.dataset.subscribeCopy!);
123
+ copyText.textContent = copy.dataset.copied!;
124
+ window.setTimeout(() => {
125
+ copyText.textContent = copyLabel;
126
+ setMenu(false);
127
+ }, 1200);
128
+ } catch {
129
+ // Clipboard refused: the button stays as it was.
130
+ }
131
+ });
132
+ }
133
+ }
134
+ if (!customElements.get("atmosphere-subscribe")) customElements.define("atmosphere-subscribe", AtmosphereSubscribe);
135
+ </script>
@@ -12,6 +12,7 @@
12
12
  // cancel show a Cancel button that closes an overlay (default false)
13
13
  import { translate, translateHtml } from "../lib/i18n";
14
14
  import { escapeHtml } from "../lib/translate";
15
+ import PostPreview from "./PostPreview.astro";
15
16
 
16
17
  interface Props {
17
18
  id: string;
@@ -31,6 +32,10 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
31
32
  <span data-context-text></span>
32
33
  <button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
33
34
  </p>
35
+ <!-- The post being replied to, when it won't be on this page (AtmosphereReactions). -->
36
+ <div class="atmo__quoted atmo__parent" data-parent hidden>
37
+ <PostPreview />
38
+ </div>
34
39
  <label for={`${id}-text`} class="atmo__as">
35
40
  <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
36
41
  <span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
@@ -53,23 +58,8 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
53
58
  <div class="atmo__preview-body">
54
59
  <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
55
60
  <p class="atmo__preview-text" data-preview-text></p>
56
- <div class="atmo__quoted">
57
- <p class="atmo__preview-author">
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> <span data-quoted-age></span>
60
- </p>
61
- <p class="atmo__preview-text" data-quoted-text></p>
62
- <div class="atmo__card" data-card hidden>
63
- <img data-card-image alt="" hidden />
64
- <div>
65
- <strong data-card-title></strong>
66
- <span data-card-description></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>
71
- </div>
72
- </div>
61
+ <div class="atmo__quoted" data-quoted-post>
62
+ <PostPreview />
73
63
  </div>
74
64
  </div>
75
65
  </div>
@@ -0,0 +1,24 @@
1
+ ---
2
+ // A Bluesky post as the composer shows it: author (avatar, name, handle,
3
+ // age), its words and its link card. Empty here, filled in by showPost() in
4
+ // lib/client/composer.ts. Used inside the Quote preview (the post being
5
+ // quoted) and above the box when replying from AtmosphereReactions (the post
6
+ // being replied to); not meant to be used on its own.
7
+ ---
8
+
9
+ <p class="atmo__preview-author">
10
+ <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
11
+ <strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
12
+ </p>
13
+ <p class="atmo__preview-text" data-quoted-text></p>
14
+ <div class="atmo__card" data-card hidden>
15
+ <img data-card-image alt="" hidden />
16
+ <div>
17
+ <strong data-card-title></strong>
18
+ <span data-card-description></span>
19
+ <span class="atmo__card-domain">
20
+ <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>
21
+ <span data-card-domain></span>
22
+ </span>
23
+ </div>
24
+ </div>
@@ -30,7 +30,14 @@ interface Props {
30
30
 
31
31
  const { id, returnTo, messages, lang, heading, permissions = [] } = Astro.props;
32
32
  const { policyUri } = settings;
33
- const t = (key: string) => translate(messages, lang, key);
33
+ const t = (key: string, vars?: Record<string, string>) => translate(messages, lang, key, vars);
34
+ // The site's name in "… can use it only for what you approved", e.g. saltedlolly.com.
35
+ let siteName = "";
36
+ try {
37
+ siteName = new URL(settings.site).hostname;
38
+ } catch {
39
+ siteName = "";
40
+ }
34
41
  const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
35
42
  // Links lead off the page, so they open in a new tab.
36
43
  const link = (href: string, text: string) =>
@@ -40,12 +47,13 @@ const link = (href: string, text: string) =>
40
47
  <form class="atmo__login" id={`${id}-login`} data-login-form>
41
48
  <label for={`${id}-handle`}>{heading ?? t("signIn.heading")}</label>
42
49
  <p
43
- class="atmo__hint"
50
+ class="atmo__hint atmo__hint--accounts"
44
51
  set:html={th("signIn.anyAccount", {
45
52
  atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
46
53
  bluesky: link("https://bsky.app", "Bluesky"),
47
- createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")),
48
- })}
54
+ }) +
55
+ "<br>" +
56
+ th("signIn.noAccount", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
49
57
  />
50
58
  <div class="atmo__row">
51
59
  <span class="atmo__handle">
@@ -84,9 +92,8 @@ const link = (href: string, text: string) =>
84
92
  {/* The detail, for anyone who wants it. */}
85
93
  <details class="atmo__details">
86
94
  <summary>{t("signIn.more")}</summary>
87
- <p class="atmo__hint">{t("signIn.privacy")}</p>
88
95
  <p class="atmo__hint">
89
- {t("signIn.access")}
96
+ {t("signIn.privacy", { site: siteName || t("signIn.thisSite") })}
90
97
  {policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
91
98
  </p>
92
99
  </details>
@@ -108,6 +115,11 @@ const link = (href: string, text: string) =>
108
115
  font-weight: 700;
109
116
  }
110
117
 
118
+ /* The two lines about accounts, above the box: a little more room. */
119
+ .atmo__hint.atmo__hint--accounts {
120
+ line-height: 1.7;
121
+ }
122
+
111
123
  .atmo__details summary {
112
124
  width: fit-content;
113
125
  color: var(--atmo-muted, inherit);
@@ -123,6 +135,8 @@ const link = (href: string, text: string) =>
123
135
  margin: 0;
124
136
  color: var(--atmo-muted, inherit);
125
137
  font-size: 0.9em;
138
+ /* Tighter than a site's body text, which is often set for long reading. */
139
+ line-height: 1.5;
126
140
  }
127
141
 
128
142
  .atmo__row {
@@ -276,6 +276,12 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
276
276
  box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
277
277
  }
278
278
 
279
+ /* <AtmosphereSubscribe align="end">: near the right-hand edge, the menu opens leftwards. */
280
+ .atmo-subscribe--end .atmo-subscribe__menu {
281
+ right: 0;
282
+ left: auto;
283
+ }
284
+
279
285
  .atmo-subscribe__menu[hidden] {
280
286
  display: none;
281
287
  }
@@ -286,6 +292,23 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
286
292
  border-bottom: 1px solid var(--atmo-border, #8886);
287
293
  color: var(--atmo-muted, inherit);
288
294
  font-size: 0.85em;
295
+ line-height: 1.5;
296
+ }
297
+
298
+ .atmo-subscribe__note p {
299
+ margin: 0;
300
+ }
301
+
302
+ /* "Follow on Bluesky →" under the note, which AtmosphereReactions turns into
303
+ a Follow / Following button for signed-in readers. */
304
+ .atmo-subscribe__follow {
305
+ display: inline-block;
306
+ margin-top: 8px;
307
+ font-weight: 700;
308
+ }
309
+
310
+ .atmo-subscribe__note .atmo-follow {
311
+ margin-top: 8px;
289
312
  }
290
313
 
291
314
  .atmo-subscribe__menu > a[hidden] {
@@ -562,6 +585,7 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
562
585
  }
563
586
 
564
587
  .atmo__preview-post p,
588
+ .atmo__quoted p,
565
589
  .atmo__card span {
566
590
  margin: 0;
567
591
  }
@@ -868,10 +892,148 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
868
892
  margin: 0;
869
893
  }
870
894
 
871
- /* In place of a comment whose author asks not to be shown to logged-out people. */
872
- :where(.atmo-comment__withheld) {
873
- margin: 0;
895
+ /* In place of a comment whose author asks not to be shown to logged-out people:
896
+ a blurred, made-up comment (grey bars, never the real one) under an explanation. */
897
+ :where(.atmo-comment--withheld article) {
898
+ position: relative;
899
+ overflow: hidden;
900
+ }
901
+
902
+ .atmo-withheld__ghost {
903
+ filter: blur(5px);
904
+ opacity: 0.7;
905
+ pointer-events: none;
906
+ user-select: none;
907
+ }
908
+
909
+ .atmo-withheld__names,
910
+ .atmo-withheld__text {
911
+ display: grid;
912
+ gap: 8px;
913
+ align-content: center;
914
+ }
915
+
916
+ .atmo-withheld__text {
917
+ margin-top: 14px;
918
+ }
919
+
920
+ .atmo-withheld__bar {
921
+ display: block;
922
+ max-width: 100%;
923
+ height: 0.75em;
924
+ border-radius: 999px;
925
+ background: color-mix(in srgb, var(--atmo-muted, currentColor) 45%, transparent);
926
+ }
927
+
928
+ .atmo-withheld__cover {
929
+ position: absolute;
930
+ inset: 0;
931
+ display: grid;
932
+ place-content: center;
933
+ gap: 4px;
934
+ padding: 12px 20px;
935
+ border: 0;
936
+ color: var(--atmo-text, inherit);
937
+ font: inherit;
938
+ text-align: center;
939
+ background: color-mix(in srgb, var(--atmo-card-bg, Canvas) 35%, transparent);
940
+ cursor: pointer;
941
+ }
942
+
943
+ .atmo-withheld__cover:disabled {
944
+ cursor: default;
945
+ }
946
+
947
+ .atmo-withheld__cover span {
948
+ max-width: 42ch;
874
949
  color: var(--atmo-muted, inherit);
875
950
  font-size: 0.9em;
876
- font-style: italic;
951
+ }
952
+
953
+ .atmo-withheld__cover:not(:disabled):hover strong,
954
+ .atmo-withheld__cover:not(:disabled):focus-visible strong {
955
+ text-decoration: underline;
956
+ }
957
+
958
+ .atmo-withheld__cover:focus-visible {
959
+ outline: 2px solid var(--atmo-accent, #1185fe);
960
+ outline-offset: -2px;
961
+ }
962
+
963
+ /* A faceless avatar: a head and shoulders. */
964
+ :where(.atmo-avatar-anonymous) {
965
+ display: inline-block;
966
+ background:
967
+ radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 18%, transparent 19%),
968
+ radial-gradient(ellipse 34% 26% at 50% 88%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 99%, transparent 100%),
969
+ var(--atmo-field-bg, #8882);
970
+ }
971
+
972
+ /* "Liked" / "Reposted" over a button, when something pressed before signing
973
+ in is finished once the reader is back (lib/client/done.ts): the button's
974
+ icon, enlarged, pops in above it with the word, then floats up and fades. */
975
+ .atmo-has-done {
976
+ position: relative;
977
+ }
978
+
979
+ .atmo-done {
980
+ position: absolute;
981
+ z-index: 30;
982
+ bottom: calc(100% + 6px);
983
+ left: 50%;
984
+ display: grid;
985
+ justify-items: center;
986
+ gap: 2px;
987
+ padding: 8px 14px 6px;
988
+ border: 1px solid var(--atmo-border, #8886);
989
+ border-radius: var(--atmo-radius, 10px);
990
+ background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
991
+ box-shadow: 0 10px 28px rgb(0 0 0 / 0.25);
992
+ color: var(--atmo-accent, #1185fe);
993
+ font-size: 0.85rem;
994
+ font-weight: 700;
995
+ line-height: 1.2;
996
+ white-space: nowrap;
997
+ pointer-events: none;
998
+ transform: translateX(-50%);
999
+ animation: atmo-done 1.8s ease-out forwards;
1000
+ }
1001
+
1002
+ /* Specific enough to beat a button's own icon size; its colours still apply. */
1003
+ .atmo-has-done .atmo-done svg.atmo-done__icon {
1004
+ width: 34px;
1005
+ height: 34px;
1006
+ animation: atmo-done-pop 0.5s cubic-bezier(0.3, 1.6, 0.5, 1) both;
1007
+ }
1008
+
1009
+ .atmo-done__label {
1010
+ color: var(--atmo-text, CanvasText);
1011
+ }
1012
+
1013
+ @keyframes atmo-done {
1014
+ 0% { opacity: 0; transform: translate(-50%, 6px); }
1015
+ 12% { opacity: 1; transform: translate(-50%, 0); }
1016
+ 75% { opacity: 1; transform: translate(-50%, 0); }
1017
+ 100% { opacity: 0; transform: translate(-50%, -10px); }
1018
+ }
1019
+
1020
+ @keyframes atmo-done-pop {
1021
+ 0% { transform: scale(0.3); }
1022
+ 60% { transform: scale(1.25); }
1023
+ 100% { transform: scale(1); }
1024
+ }
1025
+
1026
+ @media (prefers-reduced-motion: reduce) {
1027
+ .atmo-done {
1028
+ animation-name: atmo-done-fade;
1029
+ }
1030
+
1031
+ .atmo-has-done .atmo-done svg.atmo-done__icon {
1032
+ animation: none;
1033
+ }
1034
+
1035
+ @keyframes atmo-done-fade {
1036
+ 0%, 100% { opacity: 0; }
1037
+ 12%, 75% { opacity: 1; }
1038
+ }
877
1039
  }