astro-atmosphere-comments 0.2.0 → 0.4.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.
@@ -31,8 +31,12 @@
31
31
  // text, author, createdAt, quote, parent }), and after a like or repost (or
32
32
  // undoing one) `atmosphere:reacted` (detail: { kind, on, author }), so a site
33
33
  // can show it straight away.
34
- import logo from "../assets/union.svg?url";
35
34
  import settings from "../lib/settings";
35
+ import SignInForm from "./SignInForm.astro";
36
+ import Composer from "./Composer.astro";
37
+ import "./atmosphere.css";
38
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
39
+ import { escapeHtml } from "../lib/translate";
36
40
 
37
41
  interface Props {
38
42
  /**
@@ -49,15 +53,26 @@ interface Props {
49
53
  credit?: boolean;
50
54
  /** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
51
55
  blueskyLink?: boolean;
56
+ /** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
57
+ locale?: string;
58
+ /** Your own wording for any line, by its key in locales/en.json. */
59
+ messages?: Record<string, string>;
52
60
  }
53
61
 
54
- const {
55
- post = "",
56
- label = "Sign in to leave a comment, like or repost",
57
- authorLabel = "Author",
58
- credit = true,
59
- blueskyLink = true,
60
- } = Astro.props;
62
+ const { post = "", label, authorLabel, credit = true, blueskyLink = true } = Astro.props;
63
+ // The wording: the language asked for (or Astro's i18n locale for the page),
64
+ // with the site's own lines on top. The script gets the same, as JSON.
65
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
66
+ const messages = messagesFor(lang, {
67
+ ...settings.messages,
68
+ ...Astro.props.messages,
69
+ ...(label && { "signIn.heading": label }),
70
+ ...(authorLabel && { "comment.author": authorLabel }),
71
+ });
72
+ const t = (key: string, vars?: Record<string, string | number>) => translate(messages, lang, key, vars);
73
+ const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
74
+ const link = (href: string | undefined, text: string, attributes = "") =>
75
+ `<a${href ? ` href="${escapeHtml(href)}"` : ""}${attributes}>${escapeHtml(text)}</a>`;
61
76
  // The announcement on bsky.app, when the page is given it (otherwise the
62
77
  // script fills it in once the thread has loaded).
63
78
  const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
@@ -71,121 +86,41 @@ const { base } = settings;
71
86
  const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
72
87
  ---
73
88
 
74
- <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-author-label={authorLabel}>
89
+ <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
75
90
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
76
91
 
77
92
  <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
78
- <form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
79
- <label for="atmo-handle">{label}</label>
80
- <p class="atmo__hint">Any <a href="https://atmosphereaccount.com">Atmosphere account</a> works, including <a href="https://bsky.app">Bluesky</a>: one account for a whole network of open apps.</p>
81
- <div class="atmo__row">
82
- <span class="atmo__handle">
83
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
84
- <input
85
- id="atmo-handle"
86
- name="handle"
87
- type="text"
88
- inputmode="url"
89
- data-1p-ignore
90
- data-lpignore="true"
91
- data-bwignore
92
- data-form-type="other"
93
- autocapitalize="none"
94
- spellcheck="false"
95
- placeholder="alice.bsky.social"
96
- required
97
- />
98
- <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label="Matching accounts" data-handle-suggestions hidden></ul>
99
- </span>
100
- <input type="hidden" name="returnTo" value={returnTo} />
101
- <button type="submit" class="atmo__button atmo__button--sign-in">
102
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
103
- Sign in with Atmosphere
104
- </button>
105
- </div>
106
- <p class="atmo__hint">
107
- You'll approve this on your account's own site (Bluesky's, for most people); your password
108
- is never shared here.
109
- No account yet? <a href="https://atmosphereaccount.com">Create a free Atmosphere account</a>.
110
- </p>
111
- </form>
93
+ <SignInForm id="atmo" returnTo={returnTo} messages={messages} lang={lang} />
112
94
  {blueskyLink && (
113
- <p class="atmo__elsewhere" data-elsewhere hidden={!blueskyUrl}>
114
- Rather not sign in here?{" "}
115
- {/* u-syndication: microformats readers learn this is the post's copy on Bluesky. */}
116
- <a class="atmo__elsewhere-link u-syndication" href={blueskyUrl || undefined} data-elsewhere-link>Reply on Bluesky instead →</a>{" "}
117
- It shows up here just the same, as do likes and reposts.
118
- </p>
95
+ <p
96
+ class="atmo__elsewhere"
97
+ data-elsewhere
98
+ hidden={!blueskyUrl}
99
+ set:html={th("elsewhere.text", {
100
+ // u-syndication: microformats readers learn this is the post's copy on Bluesky.
101
+ link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
102
+ })}
103
+ />
119
104
  )}
120
105
  </div>
121
106
 
122
107
  <div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
123
- <div class="atmo__actions" role="group" aria-label="Respond to this post">
108
+ <div class="atmo__actions" role="group" aria-label={t("actions.label")}>
124
109
  <button type="button" class="atmo__action" data-react="like" aria-pressed="false">
125
110
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" /></svg>
126
- <span data-label-off>Like</span><span data-label-on>Liked</span>
111
+ <span data-label-off>{t("actions.like")}</span><span data-label-on>{t("actions.liked")}</span>
127
112
  </button>
128
113
  <button type="button" class="atmo__action" data-react="repost" aria-pressed="false">
129
114
  <svg viewBox="0 0 24 24" aria-hidden="true"><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" /></svg>
130
- <span data-label-off>Repost</span><span data-label-on>Reposted</span>
115
+ <span data-label-off>{t("actions.repost")}</span><span data-label-on>{t("actions.reposted")}</span>
131
116
  </button>
132
117
  <button type="button" class="atmo__action" data-quote>
133
118
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
134
- <span>Quote</span>
119
+ <span>{t("actions.quote")}</span>
135
120
  </button>
136
121
  </div>
137
122
 
138
- <form class="atmo__composer" data-composer>
139
- <p class="atmo__context" data-context hidden>
140
- <span data-context-text></span>
141
- <button type="button" class="atmo__link-button" data-cancel-context>Cancel</button>
142
- </p>
143
- <label for="atmo-text" class="atmo__as">
144
- <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
145
- <span><span data-verb>Comment</span> as <strong data-handle></strong></span>
146
- </label>
147
- <div class="atmo__field">
148
- <textarea
149
- id="atmo-text"
150
- name="text"
151
- rows="4"
152
- required
153
- aria-autocomplete="list"
154
- aria-controls="atmo-suggestions"
155
- aria-expanded="false"></textarea>
156
- <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label="Accounts to mention" data-suggestions hidden></ul>
157
- </div>
158
- <figure class="atmo__preview" data-preview hidden>
159
- <figcaption>How your post will look</figcaption>
160
- <div class="atmo__preview-post">
161
- <span class="atmo__avatar" data-preview-avatar aria-hidden="true"></span>
162
- <div class="atmo__preview-body">
163
- <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· now</span></p>
164
- <p class="atmo__preview-text" data-preview-text></p>
165
- <div class="atmo__quoted">
166
- <p class="atmo__preview-author">
167
- <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
168
- <strong data-quoted-name></strong> <span data-quoted-handle></span>
169
- </p>
170
- <p class="atmo__preview-text" data-quoted-text></p>
171
- <div class="atmo__card" data-card hidden>
172
- <img data-card-image alt="" hidden />
173
- <div>
174
- <strong data-card-title></strong>
175
- <span data-card-description></span>
176
- <span class="atmo__card-domain" data-card-domain></span>
177
- </div>
178
- </div>
179
- </div>
180
- </div>
181
- </div>
182
- </figure>
183
- <div class="atmo__row atmo__row--end">
184
- <span class="atmo__count" data-count aria-live="polite"></span>
185
- <button type="button" class="atmo__link-button" data-sign-out>Sign out</button>
186
- <button type="submit" class="atmo__button" data-submit>Post comment</button>
187
- </div>
188
- </form>
123
+ <Composer id="atmo" messages={messages} lang={lang} signOut />
189
124
  </div>
190
125
 
191
126
  <p class="atmo__status" data-status role="status" data-pagefind-ignore></p>
@@ -199,37 +134,41 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
199
134
  <slot />
200
135
 
201
136
  {credit && (
202
- <p class="atmo__credit" data-credit data-pagefind-ignore>
203
- Powered by <a href={CREDIT_URL}>Atmosphere Comments for Astro</a>
204
- </p>
137
+ <p class="atmo__credit" data-credit data-pagefind-ignore set:html={th("credit", { link: link(CREDIT_URL, "Atmosphere Comments for Astro") })} />
205
138
  )}
206
139
  </atmosphere-comments>
207
140
 
208
141
  <script>
209
- const MAX = 300;
210
- const graphemes = new Intl.Segmenter();
211
- const count = (text: string) => [...graphemes.segment(text.trim())].length;
212
-
213
- const MESSAGES: Record<string, string> = {
214
- "signed-in": "You're signed in. You can comment, like or repost above.",
215
- "sign-in-failed": "Signing in didn't finish. Please try again.",
216
- "account-not-found": "We couldn't find that account. Check the handle and try again.",
217
- "invalid-handle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
218
- "signed-out": "You've been signed out. Please sign in again.",
219
- "needs-permission": "To delete comments here, this site needs one more permission from your account.",
220
- "not-allowed": "That can't be done here.",
221
- "new-permissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
222
- "too-long": `Posts can be up to ${MAX} characters.`,
223
- "too-many": "You're going quickly. Please wait a minute and try again.",
224
- error: "That didn't work. Please try again.",
142
+ import { translate } from "../lib/translate";
143
+ import { enhanceHandleBox } from "../lib/client/handle-suggestions";
144
+ import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
145
+
146
+
147
+ // The error and status codes the routes and the sign-in round trip send,
148
+ // and the wording for each (in locales/*.json).
149
+ const MESSAGE_KEYS: Record<string, string> = {
150
+ "signed-in": "messages.signedIn",
151
+ "sign-in-failed": "messages.signInFailed",
152
+ "account-not-found": "messages.accountNotFound",
153
+ "invalid-handle": "messages.invalidHandle",
154
+ "signed-out": "messages.signedOut",
155
+ "needs-permission": "messages.needsPermission",
156
+ "not-allowed": "messages.notAllowed",
157
+ "new-permissions": "messages.newPermissions",
158
+ "too-long": "messages.tooLong",
159
+ "too-many": "messages.tooMany",
160
+ error: "messages.error",
225
161
  };
226
162
 
227
- type Posted = { uri: string; url: string; quote: boolean };
228
- type Suggestion = { handle: string; displayName?: string; avatar?: string };
229
163
 
230
164
  class AtmosphereComments extends HTMLElement {
231
165
  connectedCallback() {
232
166
  const base = this.dataset.base ?? "/atmosphere";
167
+ // The wording, in the page's language, from the server.
168
+ const lang = this.dataset.locale || "en";
169
+ const wording = JSON.parse(this.dataset.messages || "{}") as Record<string, string>;
170
+ const t = (key: string, vars?: Record<string, string | number>) => translate(wording, lang, key, vars);
171
+ const message = (code: string) => t(MESSAGE_KEYS[code] ?? MESSAGE_KEYS.error, { max: MAX });
233
172
  // The announcement's AT URI. A bsky.app address or the page's
234
173
  // Standard.site record is turned into one when the thread loads.
235
174
  let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
@@ -281,9 +220,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
281
220
  let thread: Thread | null = null;
282
221
  const reactionsBox = $<HTMLElement>("[data-reactions]");
283
222
  const repliesList = $<HTMLOListElement>("[data-replies]");
284
- const authorLabel = this.dataset.authorLabel || "Author";
223
+ const authorLabel = t("comment.author");
285
224
  const nameOf = (person: Pick<Person, "handle" | "displayName">) => person.displayName || `@${person.handle}`;
286
- const dates = new Intl.DateTimeFormat(document.documentElement.lang || undefined, {
225
+ const dates = new Intl.DateTimeFormat(lang, {
287
226
  day: "numeric",
288
227
  month: "long",
289
228
  year: "numeric",
@@ -312,7 +251,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
312
251
  ...groups.map(({ kind, count, people }) => {
313
252
  const group = make("div", "atmo-reactions__group");
314
253
  group.dataset.reaction = kind;
315
- group.append(make("p", "atmo-reactions__count", `${count} ${kind}${count === 1 ? "" : "s"}`));
254
+ group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
316
255
  const faces = make("ul", "atmo-facepile");
317
256
  for (const person of people) {
318
257
  const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener" });
@@ -345,7 +284,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
345
284
  if (reply.byAuthor) who.append(make("span", "atmo-comment__badge", authorLabel));
346
285
  const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener" });
347
286
  const time = Object.assign(make("time"), { dateTime: reply.createdAt, textContent: reply.createdAt ? dates.format(new Date(reply.createdAt)) : "" });
348
- date.append(time, make("span", "", " · view on Bluesky"));
287
+ date.append(time, make("span", "", ` · ${t("comment.viewOnBluesky")}`));
349
288
  const names = make("div");
350
289
  names.append(who, date);
351
290
  const header = make("header", "atmo-comment__header");
@@ -406,7 +345,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
406
345
  const threadReady = loadThread();
407
346
 
408
347
  // A comment just posted, shown at once (Bluesky takes a moment to list it).
409
- const showPosted = (data: Posted & { text: string; createdAt: string; parent: string | null; author: Person }) => {
348
+ const showPosted = (data: Posted) => {
410
349
  if (data.quote) return;
411
350
  const parent = data.parent
412
351
  ? repliesList.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(data.parent)}"]`)
@@ -443,98 +382,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
443
382
 
444
383
  const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
445
384
 
446
- // Matching accounts under the handle box as the reader types, with their
447
- // avatars, so they can see they've found the right one. Picking one signs
448
- // in with it. Without JavaScript the box works as a plain text box.
449
- {
450
- const input = loginForm.querySelector<HTMLInputElement>("#atmo-handle")!;
451
- const list = loginForm.querySelector<HTMLUListElement>("[data-handle-suggestions]")!;
452
- let found: Suggestion[] = [];
453
- let active = -1;
454
- let latest = "";
455
- let timer: number | undefined;
456
- input.setAttribute("role", "combobox");
457
- input.setAttribute("aria-autocomplete", "list");
458
- input.setAttribute("aria-controls", list.id);
459
- input.setAttribute("aria-expanded", "false");
460
-
461
- const close = () => {
462
- list.hidden = true;
463
- list.replaceChildren();
464
- found = [];
465
- active = -1;
466
- input.setAttribute("aria-expanded", "false");
467
- input.removeAttribute("aria-activedescendant");
468
- };
469
- const highlight = (index: number) => {
470
- active = index;
471
- list.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
472
- if (index >= 0) input.setAttribute("aria-activedescendant", `${list.id}-option-${index}`);
473
- else input.removeAttribute("aria-activedescendant");
474
- };
475
- const choose = (index: number) => {
476
- const option = found[index];
477
- if (!option) return;
478
- input.value = option.handle;
479
- close();
480
- loginForm.requestSubmit();
481
- };
482
- const show = (actors: Suggestion[]) => {
483
- found = actors.slice(0, 5);
484
- if (!found.length || document.activeElement !== input) return close();
485
- list.replaceChildren(
486
- ...found.map((option, index) => {
487
- const item = make("li");
488
- item.id = `${list.id}-option-${index}`;
489
- item.setAttribute("role", "option");
490
- const avatar = make("span", "atmo__avatar atmo__avatar--small");
491
- avatar.setAttribute("aria-hidden", "true");
492
- setAvatar(avatar, option.displayName || option.handle, option.avatar);
493
- item.append(avatar, make("strong", "", option.displayName || option.handle), make("span", "", `@${option.handle}`));
494
- // mousedown keeps the focus in the handle box.
495
- item.addEventListener("mousedown", (event) => event.preventDefault());
496
- item.addEventListener("click", () => choose(index));
497
- return item;
498
- }),
499
- );
500
- list.hidden = false;
501
- input.setAttribute("aria-expanded", "true");
502
- // Nothing is picked until the reader chooses, so Enter still signs in
503
- // with exactly what they typed.
504
- highlight(-1);
505
- };
506
-
507
- input.addEventListener("input", () => {
508
- window.clearTimeout(timer);
509
- const query = input.value.trim().replace(/^@/, "");
510
- latest = query;
511
- if (query.length < 2 || query.startsWith("did:") || /\s/.test(query)) return close();
512
- timer = window.setTimeout(async () => {
513
- try {
514
- const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
515
- const data = (await response.json()) as { actors?: Suggestion[] };
516
- if (query === latest) show(data.actors ?? []);
517
- } catch {
518
- close();
519
- }
520
- }, 250);
521
- });
522
- input.addEventListener("keydown", (event) => {
523
- if (list.hidden) return;
524
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
525
- event.preventDefault();
526
- const step = event.key === "ArrowDown" ? 1 : -1;
527
- highlight(active < 0 && step < 0 ? found.length - 1 : (active + step + found.length) % found.length);
528
- } else if (event.key === "Enter" && active >= 0) {
529
- event.preventDefault();
530
- choose(active);
531
- } else if (event.key === "Escape") {
532
- event.preventDefault();
533
- close();
534
- }
535
- });
536
- input.addEventListener("blur", () => window.setTimeout(close, 150));
537
- }
385
+ enhanceHandleBox(loginForm, base);
538
386
 
539
387
  // An avatar picture, or the name's first letter if there isn't one.
540
388
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -547,226 +395,57 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
547
395
 
548
396
  const handleError = (error: string, tell: (message: string) => void = say) => {
549
397
  if (error === "signed-out" || error === "new-permissions") void show(false);
550
- tell(MESSAGES[error] ?? MESSAGES.error);
398
+ tell(message(error));
551
399
  };
552
400
 
553
401
  const postedMessage = (where: HTMLElement, data: Posted) =>
554
402
  where.replaceChildren(
555
- data.quote ? "Your quote post is on Bluesky. " : "Your comment is posted. ",
556
- Object.assign(document.createElement("a"), { href: data.url, textContent: "See it on Bluesky →" }),
403
+ `${t(data.quote ? "posted.quote" : "posted.comment")} `,
404
+ Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
557
405
  );
558
406
 
559
- // Everything a comment box does: counting characters, @mention
560
- // suggestions and posting. Used by the main box and every reply box.
561
- const setUpComposer = (
407
+ // The comment box (lib/client/composer.ts): posts as a reply to the
408
+ // announcement, then shows it straight away and tells the page.
409
+ const composerContext = { base, t, message, setAvatar };
410
+ const composerFor = (
562
411
  form: HTMLFormElement,
563
- behaviour: {
564
- extra: () => Record<string, unknown>;
565
- tell: (message: string) => void;
566
- onPosted: (data: Posted) => void;
567
- onInput?: () => void;
568
- },
569
- ) => {
570
- const textarea = form.querySelector("textarea")!;
571
- const counter = form.querySelector<HTMLElement>("[data-count]")!;
572
- const submit = form.querySelector<HTMLButtonElement>("[data-submit]")!;
573
- const suggestions = form.querySelector<HTMLUListElement>("[data-suggestions]")!;
574
-
575
- const updateCount = () => {
576
- const used = count(textarea.value);
577
- counter.textContent = used > MAX - 50 ? `${MAX - used} left` : "";
578
- counter.classList.toggle("is-over", used > MAX);
579
- };
580
-
581
- // @mention suggestions, as in the Bluesky app: typing @ and the start
582
- // of a handle or name lists matching accounts to choose from.
583
- let options: Suggestion[] = [];
584
- let active = -1;
585
- let lookup: number | undefined;
586
- let latestQuery = "";
587
- const mentionAtCaret = () => {
588
- const before = textarea.value.slice(0, textarea.selectionStart);
589
- const match = /(?:^|[\s(])@([a-zA-Z0-9.-]{1,64})$/.exec(before);
590
- return match ? { query: match[1], start: before.length - match[1].length - 1 } : null;
591
- };
592
- const closeSuggestions = () => {
593
- suggestions.hidden = true;
594
- suggestions.replaceChildren();
595
- options = [];
596
- active = -1;
597
- textarea.setAttribute("aria-expanded", "false");
598
- textarea.removeAttribute("aria-activedescendant");
599
- };
600
- const highlight = (index: number) => {
601
- active = index;
602
- suggestions.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
603
- if (index >= 0) textarea.setAttribute("aria-activedescendant", `${suggestions.id}-option-${index}`);
604
- };
605
- const choose = (index: number) => {
606
- const mention = mentionAtCaret();
607
- const option = options[index];
608
- if (!mention || !option) return closeSuggestions();
609
- const after = textarea.value.slice(textarea.selectionStart);
610
- textarea.value = `${textarea.value.slice(0, mention.start)}@${option.handle} ${after}`;
611
- const caret = mention.start + option.handle.length + 2;
612
- textarea.setSelectionRange(caret, caret);
613
- closeSuggestions();
614
- textarea.focus();
615
- updateCount();
616
- behaviour.onInput?.();
617
- };
618
- const showSuggestions = (found: Suggestion[]) => {
619
- options = found;
620
- if (!found.length) return closeSuggestions();
621
- suggestions.replaceChildren(
622
- ...found.map((option, index) => {
623
- const item = document.createElement("li");
624
- item.id = `${suggestions.id}-option-${index}`;
625
- item.setAttribute("role", "option");
626
- const avatar = document.createElement("span");
627
- avatar.className = "atmo__avatar atmo__avatar--small";
628
- setAvatar(avatar, option.displayName || option.handle, option.avatar);
629
- const name = Object.assign(document.createElement("strong"), { textContent: option.displayName || option.handle });
630
- const handle = Object.assign(document.createElement("span"), { textContent: `@${option.handle}` });
631
- item.append(avatar, name, handle);
632
- // mousedown keeps the focus in the text box.
633
- item.addEventListener("mousedown", (event) => event.preventDefault());
634
- item.addEventListener("click", () => choose(index));
635
- return item;
636
- }),
637
- );
638
- suggestions.hidden = false;
639
- textarea.setAttribute("aria-expanded", "true");
640
- highlight(0);
641
- };
642
- const lookUpMention = () => {
643
- window.clearTimeout(lookup);
644
- const mention = mentionAtCaret();
645
- if (!mention) return closeSuggestions();
646
- latestQuery = mention.query;
647
- lookup = window.setTimeout(async () => {
648
- const query = mention.query;
649
- try {
650
- const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
651
- const data = (await response.json()) as { actors?: Suggestion[] };
652
- if (query === latestQuery) showSuggestions(data.actors ?? []);
653
- } catch {
654
- closeSuggestions();
655
- }
656
- }, 200);
657
- };
658
-
659
- textarea.addEventListener("input", () => {
660
- updateCount();
661
- behaviour.onInput?.();
662
- lookUpMention();
663
- });
664
- textarea.addEventListener("keydown", (event) => {
665
- if (suggestions.hidden) return;
666
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
667
- event.preventDefault();
668
- const step = event.key === "ArrowDown" ? 1 : -1;
669
- highlight((active + step + options.length) % options.length);
670
- } else if (event.key === "Enter" || event.key === "Tab") {
671
- event.preventDefault();
672
- choose(Math.max(active, 0));
673
- } else if (event.key === "Escape") {
674
- event.preventDefault();
675
- closeSuggestions();
676
- }
677
- });
678
- textarea.addEventListener("blur", () => window.setTimeout(closeSuggestions, 150));
679
-
680
- form.addEventListener("submit", async (event) => {
681
- event.preventDefault();
682
- const text = textarea.value.trim();
683
- if (!text) return;
684
- if (count(text) > MAX) return behaviour.tell(MESSAGES["too-long"]);
685
- submit.disabled = true;
686
- behaviour.tell("Posting…");
687
- try {
688
- const response = await send("comment", { text, root: post, ...behaviour.extra() });
689
- const data = await response.json();
690
- if (!response.ok) return handleError(data.error, behaviour.tell);
691
- textarea.value = "";
692
- updateCount();
412
+ behaviour: Omit<ComposerBehaviour, "root" | "onError" | "onPosted"> & { onPosted: (data: Posted) => void },
413
+ ) =>
414
+ setUpComposer(form, composerContext, {
415
+ ...behaviour,
416
+ root: () => post,
417
+ onError: handleError,
418
+ onPosted: (data) => {
693
419
  behaviour.onPosted(data);
694
420
  showPosted(data);
695
421
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
696
- } catch {
697
- behaviour.tell(MESSAGES.error);
698
- } finally {
699
- submit.disabled = false;
700
- }
422
+ },
701
423
  });
702
424
 
703
- return { textarea, updateCount };
704
- };
705
-
706
- // The Quote preview: the reader's words as they type, with the
707
- // announcement quoted beneath, as Bluesky will show it.
425
+ // The Quote preview (lib/client/composer.ts).
708
426
  const preview = $<HTMLElement>("[data-preview]");
709
- const previewText = $<HTMLElement>("[data-preview-text]");
710
- let quotedLoaded = false;
711
- const updatePreviewText = () => {
712
- const words = main.textarea.value.trim();
713
- previewText.textContent = words || "Your words appear here.";
714
- previewText.classList.toggle("is-placeholder", !words);
715
- };
716
- const loadQuoted = async () => {
717
- updatePreviewText();
718
- if (quotedLoaded) return;
719
- try {
720
- const response = await fetch(`${base}/post?uri=${encodeURIComponent(post)}`);
721
- if (!response.ok) return;
722
- const quoted = (await response.json()) as {
723
- author: { handle: string; displayName?: string; avatar?: string };
724
- text: string;
725
- external?: { uri: string; title: string; description: string; thumb?: string };
726
- };
727
- quotedLoaded = true;
728
- const quotedName = quoted.author.displayName || quoted.author.handle;
729
- $<HTMLElement>("[data-quoted-name]").textContent = quotedName;
730
- setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
731
- $<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
732
- $<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
733
- if (quoted.external) {
734
- $<HTMLElement>("[data-card]").hidden = false;
735
- $<HTMLElement>("[data-card-title]").textContent = quoted.external.title;
736
- $<HTMLElement>("[data-card-description]").textContent = quoted.external.description;
737
- $<HTMLElement>("[data-card-domain]").textContent = new URL(quoted.external.uri).hostname;
738
- if (quoted.external.thumb) {
739
- const img = $<HTMLImageElement>("[data-card-image]");
740
- img.onload = () => (img.hidden = false);
741
- img.onerror = () => (img.hidden = true);
742
- img.src = quoted.external.thumb;
743
- }
744
- }
745
- } catch {
746
- // The preview is a nicety: quoting still works without it.
747
- }
748
- };
749
427
 
750
428
  // The main box: a comment on the post, or (after Quote) a quote post.
751
429
  const setMainMode = (next: "comment" | "quote") => {
752
430
  mainMode = next;
753
431
  const quoting = next === "quote";
754
432
  $<HTMLElement>("[data-context]").hidden = !quoting;
755
- $<HTMLElement>("[data-context-text]").textContent = quoting ? "Quoting this post on your own account" : "";
756
- $<HTMLElement>("[data-verb]").textContent = quoting ? "Quote" : "Comment";
757
- $<HTMLElement>("[data-submit]").textContent = quoting ? "Post quote" : "Post comment";
433
+ $<HTMLElement>("[data-context-text]").textContent = quoting ? t("composer.quoting") : "";
434
+ $<HTMLElement>("[data-verb]").textContent = t(quoting ? "composer.quote" : "composer.comment");
435
+ $<HTMLElement>("[data-submit]").textContent = t(quoting ? "composer.postQuote" : "composer.postComment");
758
436
  preview.hidden = !quoting;
759
- if (quoting) void loadQuoted();
437
+ if (quoting) void quote.load(post);
760
438
  };
761
- const main = setUpComposer(composer, {
439
+ const main = composerFor(composer, {
762
440
  extra: () => (mainMode === "quote" ? { quote: true } : {}),
763
441
  tell: say,
764
442
  onPosted: (data) => {
765
443
  setMainMode("comment");
766
444
  postedMessage(status, data);
767
445
  },
768
- onInput: updatePreviewText,
446
+ onInput: () => quote.update(),
769
447
  });
448
+ const quote = quotePreview(composer, main.textarea, composerContext);
770
449
  $<HTMLButtonElement>("[data-cancel-context]").addEventListener("click", () => {
771
450
  setMainMode("comment");
772
451
  main.textarea.focus();
@@ -795,9 +474,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
795
474
  suggestions.id = `${id}-suggestions`;
796
475
  textarea.setAttribute("aria-controls", suggestions.id);
797
476
  form.querySelector<HTMLElement>("[data-context]")!.hidden = false;
798
- form.querySelector("[data-context-text]")!.textContent = `Replying to ${author}`;
799
- form.querySelector("[data-verb]")!.textContent = "Reply";
800
- form.querySelector("[data-submit]")!.textContent = "Post reply";
477
+ form.querySelector("[data-context-text]")!.textContent = t("composer.replyingTo", { name: author });
478
+ form.querySelector("[data-verb]")!.textContent = t("comment.reply");
479
+ form.querySelector("[data-submit]")!.textContent = t("composer.postReply");
801
480
  form.querySelector("[data-handle]")!.textContent = me.handle ? `@${me.handle}` : "";
802
481
  setAvatar(form.querySelector<HTMLElement>("[data-me-avatar]")!, me.displayName || me.handle || "?", me.avatar);
803
482
  const replyStatus = scoped(Object.assign(document.createElement("p"), { className: "atmo__status" }));
@@ -806,7 +485,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
806
485
  (comment.querySelector("article") ?? comment).append(holder);
807
486
  const close = () => holder.remove();
808
487
  form.querySelector("[data-cancel-context]")!.addEventListener("click", close);
809
- setUpComposer(form, {
488
+ composerFor(form, {
810
489
  extra: () => ({ parent: comment.dataset.atmosphereUri }),
811
490
  tell: (message) => (replyStatus.textContent = message),
812
491
  // The reply appears under the comment (the page adds it), so the box goes.
@@ -835,8 +514,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
835
514
  button.dataset.kind = kind;
836
515
  const seen = known[comment.dataset.atmosphereUri!];
837
516
  button.setAttribute("aria-pressed", String(!!(kind === "like" ? seen?.liked : seen?.reposted)));
838
- const author = comment.dataset.atmosphereAuthor || "this comment";
839
- button.setAttribute("aria-label", `${kind === "like" ? "Like" : "Repost"} ${author}'s comment`);
517
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
518
+ button.setAttribute("aria-label", t(kind === "like" ? "comment.likeLabel" : "comment.repostLabel", { name: author }));
840
519
  const count = Number(comment.dataset[kind === "like" ? "atmosphereLikes" : "atmosphereReposts"] || 0);
841
520
  button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${kind === "like" ? HEART : ARROWS}</svg><span></span>`;
842
521
  const setCount = (value: number) => {
@@ -845,7 +524,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
845
524
  };
846
525
  setCount(count);
847
526
  button.addEventListener("click", async () => {
848
- if (!isSignedIn) return signInFirst(`Sign in to ${kind} comments.`);
527
+ if (!isSignedIn) return signInFirst(t(kind === "like" ? "signInFirst.like" : "signInFirst.repost"));
849
528
  const on = button.getAttribute("aria-pressed") !== "true";
850
529
  const before = Number(button.dataset.count || 0);
851
530
  button.setAttribute("aria-pressed", String(on));
@@ -869,15 +548,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
869
548
  if (comment.querySelector(":scope .atmo-comment-actions")) return;
870
549
  added += 1;
871
550
  const actions = Object.assign(document.createElement("div"), { className: "atmo-comment-actions" });
872
- const author = comment.dataset.atmosphereAuthor || "this comment";
551
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
873
552
  const reply = Object.assign(document.createElement("button"), {
874
553
  type: "button",
875
554
  className: "atmo-comment-action",
876
- textContent: "Reply",
555
+ textContent: t("comment.reply"),
877
556
  });
878
- reply.setAttribute("aria-label", `Reply to ${author}`);
557
+ reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
879
558
  reply.addEventListener("click", () => {
880
- if (!isSignedIn) return signInFirst("Sign in to reply.");
559
+ if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
881
560
  openReply(comment, author);
882
561
  });
883
562
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
@@ -892,13 +571,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
892
571
 
893
572
  const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
894
573
  const uri = comment.dataset.atmosphereUri!;
895
- panel.replaceChildren(make("span", "atmo-comment-delete__text", "Deleting…"));
574
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.deleting")));
896
575
  try {
897
576
  const response = await send("delete", { post: uri });
898
577
  const data = (await response.json()) as { error?: string };
899
578
  if (response.ok) {
900
579
  comment.remove();
901
- say("Your comment was deleted.");
580
+ say(t("comment.deleted"));
902
581
  this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
903
582
  return;
904
583
  }
@@ -907,7 +586,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
907
586
  handleError(data.error ?? "error");
908
587
  } catch {
909
588
  panel.remove();
910
- say(MESSAGES.error);
589
+ say(message("error"));
911
590
  }
912
591
  };
913
592
 
@@ -920,15 +599,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
920
599
  field("handle", me.did!);
921
600
  field("permission", "delete");
922
601
  field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
923
- const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Continue to approve"), { type: "submit" });
924
- const cancel = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
602
+ const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
603
+ const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
925
604
  cancel.addEventListener("click", () => panel.remove());
926
605
  form.append(go, cancel);
927
606
  panel.replaceChildren(
928
607
  make(
929
608
  "span",
930
609
  "atmo-comment-delete__text",
931
- "To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
610
+ t("comment.deletePermission"),
932
611
  ),
933
612
  form,
934
613
  );
@@ -936,22 +615,22 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
936
615
  };
937
616
 
938
617
  const deleteButton = (comment: HTMLElement) => {
939
- const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", "Delete"), { type: "button" });
940
- button.setAttribute("aria-label", "Delete your comment");
618
+ const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
619
+ button.setAttribute("aria-label", t("comment.deleteLabel"));
941
620
  button.addEventListener("click", () => {
942
621
  const actions = button.parentElement!;
943
622
  if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
944
623
  const panel = make("div", "atmo-comment-delete");
945
- const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Delete"), { type: "button" });
946
- const no = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
624
+ const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
625
+ const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
947
626
  yes.addEventListener("click", () => deleteComment(comment, panel));
948
627
  no.addEventListener("click", () => {
949
628
  panel.remove();
950
629
  button.focus();
951
630
  });
952
- panel.append(make("span", "atmo-comment-delete__text", "Delete this comment from Bluesky?"), yes, no);
631
+ panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
953
632
  panel.setAttribute("role", "group");
954
- panel.setAttribute("aria-label", "Delete your comment");
633
+ panel.setAttribute("aria-label", t("comment.deleteLabel"));
955
634
  actions.after(panel);
956
635
  no.focus();
957
636
  });
@@ -1033,7 +712,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1033
712
  const url = new URL(location.href);
1034
713
  const result = url.searchParams.get("atmosphere");
1035
714
  if (result) {
1036
- say(MESSAGES[result] ?? "");
715
+ say(MESSAGE_KEYS[result] ? message(result) : "");
1037
716
  url.searchParams.delete("atmosphere");
1038
717
  history.replaceState(history.state, "", url);
1039
718
  }
@@ -1072,7 +751,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1072
751
  setPressed(kind, !on);
1073
752
  return handleError((await response.json()).error);
1074
753
  }
1075
- say(on ? (kind === "like" ? "Liked." : "Reposted.") : kind === "like" ? "Like removed." : "Repost removed.");
754
+ say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
1076
755
  showReaction(kind, on);
1077
756
  this.dispatchEvent(
1078
757
  new CustomEvent("atmosphere:reacted", {
@@ -1082,7 +761,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1082
761
  );
1083
762
  } catch {
1084
763
  setPressed(kind, !on);
1085
- say(MESSAGES.error);
764
+ say(message("error"));
1086
765
  }
1087
766
  });
1088
767
  }
@@ -1091,656 +770,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1091
770
  await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
1092
771
  void show(false);
1093
772
  setMainMode("comment");
1094
- say("You're signed out.");
773
+ say(t("status.signedOut"));
1095
774
  });
1096
775
  }
1097
776
  }
1098
777
 
1099
778
  customElements.define("atmosphere-comments", AtmosphereComments);
1100
779
  </script>
1101
-
1102
- <style>
1103
- .atmo {
1104
- display: block;
1105
- color: var(--atmo-text, currentColor);
1106
- }
1107
-
1108
- /* Only the component's own parts wait for sign-in to be checked; the
1109
- site's slot content shows straight away. */
1110
- .atmo.is-loading > :is([data-view], [data-reactions], [data-replies]) {
1111
- visibility: hidden;
1112
- }
1113
-
1114
- .atmo [hidden] {
1115
- display: none !important;
1116
- }
1117
-
1118
- /* The Atmosphere Account logo inside the handle box, on the left, and at
1119
- the start of the sign-in button. It is single-colour, so it is drawn in
1120
- the muted text colour in the box and the button's text colour on it. */
1121
- .atmo__handle {
1122
- position: relative;
1123
- display: flex;
1124
- flex: 1 1 220px;
1125
- min-width: 0;
1126
- }
1127
-
1128
- /* Matching accounts sit under the handle box, as wide as it. */
1129
- .atmo__handle .atmo__suggestions {
1130
- width: 100%;
1131
- }
1132
-
1133
- .atmo__handle .atmo__logo {
1134
- position: absolute;
1135
- top: 50%;
1136
- left: 11px;
1137
- width: 20px;
1138
- height: 20px;
1139
- transform: translateY(-50%);
1140
- background-color: var(--atmo-muted, currentColor);
1141
- mask: var(--atmo-logo) center / contain no-repeat;
1142
- pointer-events: none;
1143
- }
1144
-
1145
- .atmo .atmo__handle input[type="text"] {
1146
- padding-left: 40px;
1147
- }
1148
-
1149
- .atmo__button--sign-in {
1150
- display: inline-flex;
1151
- align-items: center;
1152
- gap: 8px;
1153
- }
1154
-
1155
- .atmo__button--sign-in .atmo__logo {
1156
- flex: none;
1157
- width: 20px;
1158
- height: 20px;
1159
- background-color: currentColor;
1160
- mask: var(--atmo-logo) center / contain no-repeat;
1161
- }
1162
-
1163
- /* The comment box when it sits under a comment being replied to. */
1164
- .atmo--inline {
1165
- margin: 4px 0 6px;
1166
- }
1167
-
1168
- .atmo--inline .atmo__composer {
1169
- margin-top: 10px;
1170
- }
1171
-
1172
- .atmo__elsewhere {
1173
- max-width: 560px;
1174
- margin: 14px 0 0;
1175
- font-size: 0.9em;
1176
- }
1177
-
1178
- .atmo__elsewhere-link {
1179
- font-weight: 700;
1180
- }
1181
-
1182
- .atmo__login,
1183
- .atmo__composer {
1184
- display: grid;
1185
- gap: 8px;
1186
- max-width: 560px;
1187
- margin-top: 14px;
1188
- }
1189
-
1190
- .atmo label {
1191
- font-weight: 700;
1192
- }
1193
-
1194
- .atmo__row {
1195
- display: flex;
1196
- flex-wrap: wrap;
1197
- align-items: center;
1198
- gap: 10px;
1199
- }
1200
-
1201
- .atmo__row--end {
1202
- justify-content: flex-end;
1203
- }
1204
-
1205
- .atmo input[type="text"],
1206
- .atmo textarea {
1207
- flex: 1 1 220px;
1208
- min-width: 0;
1209
- padding: 10px 12px;
1210
- border: 1px solid var(--atmo-border, #8886);
1211
- border-radius: var(--atmo-radius, 10px);
1212
- background: var(--atmo-field-bg, transparent);
1213
- color: inherit;
1214
- font: inherit;
1215
- }
1216
-
1217
- .atmo textarea {
1218
- resize: vertical;
1219
- }
1220
-
1221
- .atmo__button {
1222
- padding: 10px 16px;
1223
- border: 0;
1224
- border-radius: var(--atmo-radius, 10px);
1225
- background: var(--atmo-accent, #1185fe);
1226
- color: var(--atmo-on-accent, #fff);
1227
- font: inherit;
1228
- font-weight: 700;
1229
- cursor: pointer;
1230
- }
1231
-
1232
- .atmo__button:disabled {
1233
- opacity: 0.6;
1234
- cursor: progress;
1235
- }
1236
-
1237
- .atmo__link-button {
1238
- padding: 0;
1239
- border: 0;
1240
- background: none;
1241
- color: var(--atmo-muted, inherit);
1242
- font: inherit;
1243
- text-decoration: underline;
1244
- cursor: pointer;
1245
- }
1246
-
1247
- /* Like, Repost and Quote: outlined pills that fill in once done. */
1248
- .atmo__actions {
1249
- display: flex;
1250
- flex-wrap: wrap;
1251
- gap: 8px;
1252
- }
1253
-
1254
- .atmo__action {
1255
- display: inline-flex;
1256
- align-items: center;
1257
- gap: 6px;
1258
- padding: 7px 14px 7px 10px;
1259
- border: 1px solid var(--atmo-border, #8886);
1260
- border-radius: 999px;
1261
- background: none;
1262
- color: inherit;
1263
- font: inherit;
1264
- font-weight: 600;
1265
- cursor: pointer;
1266
- }
1267
-
1268
- .atmo__action:hover,
1269
- .atmo__action:focus-visible {
1270
- border-color: var(--atmo-accent, #1185fe);
1271
- color: var(--atmo-accent, #1185fe);
1272
- }
1273
-
1274
- .atmo__action svg {
1275
- width: 18px;
1276
- height: 18px;
1277
- fill: none;
1278
- stroke: currentColor;
1279
- stroke-width: 1.8;
1280
- stroke-linecap: round;
1281
- stroke-linejoin: round;
1282
- }
1283
-
1284
- .atmo__action[aria-pressed="true"] {
1285
- border-color: var(--atmo-accent, #1185fe);
1286
- color: var(--atmo-accent, #1185fe);
1287
- }
1288
-
1289
- .atmo__action[data-react="like"][aria-pressed="true"] svg {
1290
- fill: currentColor;
1291
- }
1292
-
1293
- .atmo__action [data-label-on],
1294
- .atmo__action[aria-pressed="true"] [data-label-off] {
1295
- display: none;
1296
- }
1297
-
1298
- .atmo__action[aria-pressed="true"] [data-label-on] {
1299
- display: inline;
1300
- }
1301
-
1302
- .atmo__context {
1303
- display: flex;
1304
- flex-wrap: wrap;
1305
- gap: 10px;
1306
- margin: 0;
1307
- color: var(--atmo-muted, inherit);
1308
- font-size: 0.9em;
1309
- }
1310
-
1311
- .atmo__hint,
1312
- .atmo__count,
1313
- .atmo__status {
1314
- margin: 0;
1315
- color: var(--atmo-muted, inherit);
1316
- font-size: 0.9em;
1317
- }
1318
-
1319
- .atmo__count {
1320
- margin-right: auto;
1321
- }
1322
-
1323
- .atmo__count.is-over {
1324
- color: var(--atmo-error, #d33);
1325
- }
1326
-
1327
- .atmo__status:not(:empty) {
1328
- margin-top: 10px;
1329
- }
1330
-
1331
- .atmo__credit {
1332
- margin: 14px 0 0;
1333
- color: var(--atmo-muted, inherit);
1334
- font-size: 0.75em;
1335
- opacity: 0.8;
1336
- }
1337
-
1338
- .atmo__credit a {
1339
- color: inherit;
1340
- }
1341
-
1342
- /* @mention suggestions, under the text box. */
1343
- .atmo__field {
1344
- position: relative;
1345
- display: grid;
1346
- }
1347
-
1348
- .atmo__suggestions {
1349
- position: absolute;
1350
- z-index: 5;
1351
- top: calc(100% + 4px);
1352
- left: 0;
1353
- width: min(100%, 360px);
1354
- margin: 0;
1355
- padding: 4px;
1356
- list-style: none;
1357
- border: 1px solid var(--atmo-border, #8886);
1358
- border-radius: var(--atmo-radius, 10px);
1359
- background: var(--atmo-menu-bg, Canvas);
1360
- box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
1361
- }
1362
-
1363
- .atmo__suggestions :global(li) {
1364
- display: flex;
1365
- align-items: center;
1366
- gap: 8px;
1367
- padding: 7px 9px;
1368
- border-radius: calc(var(--atmo-radius, 10px) - 4px);
1369
- cursor: pointer;
1370
- }
1371
-
1372
- .atmo__suggestions :global(li[aria-selected="true"]),
1373
- .atmo__suggestions :global(li:hover) {
1374
- background: color-mix(in srgb, var(--atmo-accent, #1185fe) 14%, transparent);
1375
- }
1376
-
1377
- .atmo__suggestions :global(li span:last-child) {
1378
- overflow: hidden;
1379
- color: var(--atmo-muted, inherit);
1380
- text-overflow: ellipsis;
1381
- white-space: nowrap;
1382
- }
1383
-
1384
- .atmo__suggestions :global(.atmo__avatar) {
1385
- display: grid;
1386
- flex: none;
1387
- place-items: center;
1388
- width: 26px;
1389
- height: 26px;
1390
- overflow: hidden;
1391
- border-radius: 50%;
1392
- background: var(--atmo-border, #8886);
1393
- font-size: 11px;
1394
- font-weight: 700;
1395
- }
1396
-
1397
- .atmo__suggestions :global(.atmo__avatar img) {
1398
- width: 100%;
1399
- height: 100%;
1400
- object-fit: cover;
1401
- }
1402
-
1403
- /* The Quote preview: a post card with the quoted post nested inside. */
1404
- .atmo__preview {
1405
- display: grid;
1406
- gap: 6px;
1407
- margin: 4px 0 0;
1408
- }
1409
-
1410
- .atmo__preview figcaption {
1411
- color: var(--atmo-muted, inherit);
1412
- font-size: 0.85em;
1413
- font-weight: 700;
1414
- }
1415
-
1416
- .atmo__preview-post,
1417
- .atmo__quoted {
1418
- padding: 12px 14px;
1419
- border: 1px solid var(--atmo-border, #8886);
1420
- border-radius: var(--atmo-radius, 10px);
1421
- }
1422
-
1423
- /* As on Bluesky: the avatar beside the post, the post's parts stacked. */
1424
- .atmo__preview-post {
1425
- display: grid;
1426
- grid-template-columns: auto minmax(0, 1fr);
1427
- gap: 12px;
1428
- }
1429
-
1430
- .atmo__preview-body,
1431
- .atmo__quoted {
1432
- display: grid;
1433
- gap: 6px;
1434
- min-width: 0;
1435
- }
1436
-
1437
- .atmo__avatar {
1438
- display: grid;
1439
- place-items: center;
1440
- width: 42px;
1441
- height: 42px;
1442
- overflow: hidden;
1443
- border-radius: 50%;
1444
- background: var(--atmo-border, #8886);
1445
- color: var(--atmo-text, inherit);
1446
- font-weight: 700;
1447
- }
1448
-
1449
- /* "Comment as @handle", with the reader's avatar as a reminder of which
1450
- account they're signed in with. */
1451
- .atmo__as {
1452
- display: flex;
1453
- align-items: center;
1454
- gap: 8px;
1455
- }
1456
-
1457
- .atmo__avatar--me {
1458
- width: 28px;
1459
- height: 28px;
1460
- font-size: 12px;
1461
- }
1462
-
1463
- .atmo__avatar--small {
1464
- display: inline-grid;
1465
- width: 18px;
1466
- height: 18px;
1467
- margin-right: 4px;
1468
- font-size: 10px;
1469
- vertical-align: -4px;
1470
- }
1471
-
1472
- /* Added by the script, so outside Astro's scoped styles. */
1473
- .atmo__avatar :global(img) {
1474
- width: 100%;
1475
- height: 100%;
1476
- object-fit: cover;
1477
- }
1478
-
1479
- .atmo__preview-post p,
1480
- .atmo__card span {
1481
- margin: 0;
1482
- }
1483
-
1484
- .atmo__preview-author span,
1485
- .atmo__card-domain {
1486
- color: var(--atmo-muted, inherit);
1487
- }
1488
-
1489
- .atmo__preview-text {
1490
- white-space: pre-wrap;
1491
- overflow-wrap: anywhere;
1492
- }
1493
-
1494
- .atmo__preview-text.is-placeholder {
1495
- color: var(--atmo-muted, inherit);
1496
- font-style: italic;
1497
- }
1498
-
1499
- .atmo__quoted {
1500
- font-size: 0.92em;
1501
- }
1502
-
1503
- .atmo__card {
1504
- overflow: hidden;
1505
- border: 1px solid var(--atmo-border, #8886);
1506
- border-radius: var(--atmo-radius, 10px);
1507
- }
1508
-
1509
- .atmo__card img {
1510
- display: block;
1511
- width: 100%;
1512
- aspect-ratio: 1.91 / 1;
1513
- object-fit: cover;
1514
- }
1515
-
1516
- .atmo__card > div {
1517
- display: grid;
1518
- gap: 2px;
1519
- padding: 10px 12px;
1520
- }
1521
-
1522
- .atmo__card [data-card-description] {
1523
- display: -webkit-box;
1524
- overflow: hidden;
1525
- -webkit-box-orient: vertical;
1526
- -webkit-line-clamp: 2;
1527
- }
1528
-
1529
- .atmo__card-domain {
1530
- font-size: 0.85em;
1531
- }
1532
-
1533
- /* The likes and reposts, and the comments. These are drawn by the script,
1534
- and wrapped in :where() so they carry no weight: any rule a site writes
1535
- for these classes wins. */
1536
- :global(:where(.atmo-reactions)) {
1537
- display: flex;
1538
- flex-wrap: wrap;
1539
- gap: 8px 32px;
1540
- margin: 0 0 18px;
1541
- }
1542
-
1543
- :global(:where(.atmo-reactions__count)) {
1544
- margin: 0 0 8px;
1545
- font-weight: 700;
1546
- }
1547
-
1548
- :global(:where(.atmo-facepile)) {
1549
- display: flex;
1550
- flex-wrap: wrap;
1551
- margin: 0;
1552
- padding: 0 0 0 8px;
1553
- list-style: none;
1554
- }
1555
-
1556
- :global(:where(.atmo-facepile li)) {
1557
- margin-left: -8px;
1558
- }
1559
-
1560
- :global(:where(.atmo-facepile a)) {
1561
- display: block;
1562
- border-radius: 50%;
1563
- }
1564
-
1565
- :global(:where(.atmo-facepile__avatar, .atmo-facepile__more)) {
1566
- display: grid;
1567
- place-items: center;
1568
- width: 36px;
1569
- height: 36px;
1570
- border: 2px solid var(--atmo-ring, Canvas);
1571
- border-radius: 50%;
1572
- object-fit: cover;
1573
- background: var(--atmo-field-bg, #8882);
1574
- font-size: 12px;
1575
- }
1576
-
1577
- :global(:where(.atmo-facepile__more)) {
1578
- width: auto;
1579
- min-width: 36px;
1580
- padding: 0 8px;
1581
- border-radius: 18px;
1582
- }
1583
-
1584
- :global(:where(.atmo-avatar-initial)) {
1585
- display: inline-grid;
1586
- place-items: center;
1587
- color: #fff;
1588
- font-weight: 800;
1589
- background: var(--atmo-accent, #1185fe);
1590
- }
1591
-
1592
- :global(:where(.atmo-comments)) {
1593
- display: grid;
1594
- gap: 12px;
1595
- margin: 24px 0 0;
1596
- padding: 0;
1597
- list-style: none;
1598
- }
1599
-
1600
- :global(:where(.atmo-comments:empty)) {
1601
- display: none;
1602
- }
1603
-
1604
- :global(:where(.atmo-comment article)) {
1605
- padding: 16px 18px;
1606
- border: 1px solid var(--atmo-border, #8886);
1607
- border-radius: 16px;
1608
- background: var(--atmo-card-bg, transparent);
1609
- }
1610
-
1611
- :global(:where(.atmo-comment--author > article)) {
1612
- border-color: var(--atmo-accent, #1185fe);
1613
- }
1614
-
1615
- /* Replies step in under the comment they answer (up to three levels). */
1616
- :global(:where(.atmo-comment--nested)) {
1617
- margin-left: calc((var(--depth, 1) - 1) * 24px);
1618
- padding-left: 22px;
1619
- border-left: 2px solid var(--atmo-border, #8886);
1620
- }
1621
-
1622
- :global(:where(.atmo-comment__header)) {
1623
- display: flex;
1624
- align-items: center;
1625
- gap: 12px;
1626
- }
1627
-
1628
- :global(:where(.atmo-comment__avatar)) {
1629
- flex: none;
1630
- width: 40px;
1631
- height: 40px;
1632
- border-radius: 50%;
1633
- object-fit: cover;
1634
- }
1635
-
1636
- :global(:where(.atmo-comment__who)) {
1637
- display: flex;
1638
- flex-wrap: wrap;
1639
- align-items: center;
1640
- gap: 8px;
1641
- }
1642
-
1643
- :global(:where(.atmo-comment__author)) {
1644
- color: inherit;
1645
- font-weight: 750;
1646
- text-decoration: none;
1647
- }
1648
-
1649
- :global(:where(.atmo-comment__badge)) {
1650
- padding: 2px 7px;
1651
- border: 1px solid var(--atmo-accent, #1185fe);
1652
- border-radius: 999px;
1653
- color: var(--atmo-accent, #1185fe);
1654
- font-size: 11px;
1655
- font-weight: 800;
1656
- letter-spacing: 0.04em;
1657
- text-transform: uppercase;
1658
- }
1659
-
1660
- :global(:where(.atmo-comment__date)) {
1661
- color: var(--atmo-muted, inherit);
1662
- font-size: 13px;
1663
- text-decoration: none;
1664
- }
1665
-
1666
- :global(:where(.atmo-comment__body)) {
1667
- margin-top: 10px;
1668
- line-height: 1.65;
1669
- overflow-wrap: anywhere;
1670
- white-space: pre-line;
1671
- }
1672
-
1673
- :global(:where(.atmo-comment__body p)) {
1674
- margin: 0;
1675
- }
1676
-
1677
- /* Reply, Like and Repost under each comment (outside this element, so
1678
- unscoped). */
1679
- :global(.atmo-comment-actions) {
1680
- display: flex;
1681
- flex-wrap: wrap;
1682
- gap: 18px;
1683
- margin-top: 8px;
1684
- }
1685
-
1686
- :global(.atmo-comment-delete) {
1687
- display: flex;
1688
- flex-wrap: wrap;
1689
- align-items: center;
1690
- gap: 6px 12px;
1691
- margin-top: 8px;
1692
- font-size: 0.9em;
1693
- }
1694
-
1695
- :global(.atmo-comment-delete form) {
1696
- display: contents;
1697
- }
1698
-
1699
- :global(.atmo-comment-delete__text) {
1700
- color: var(--atmo-muted, inherit);
1701
- }
1702
-
1703
-
1704
- :global(.atmo-comment-action) {
1705
- display: inline-flex;
1706
- align-items: center;
1707
- gap: 5px;
1708
- padding: 0;
1709
- border: 0;
1710
- background: none;
1711
- color: var(--atmo-muted, inherit);
1712
- font: inherit;
1713
- font-size: 0.85em;
1714
- font-weight: 600;
1715
- cursor: pointer;
1716
- }
1717
-
1718
- :global(.atmo-comment-action--delete) {
1719
- margin-left: auto;
1720
- }
1721
-
1722
- :global(.atmo-comment-action--danger) {
1723
- color: var(--atmo-danger, #c0392b);
1724
- font-weight: 700;
1725
- }
1726
-
1727
- :global(.atmo-comment-action:hover),
1728
- :global(.atmo-comment-action:focus-visible),
1729
- :global(.atmo-comment-action[aria-pressed="true"]) {
1730
- color: var(--atmo-accent, #1185fe);
1731
- }
1732
-
1733
- :global(.atmo-comment-action svg) {
1734
- width: 16px;
1735
- height: 16px;
1736
- fill: none;
1737
- stroke: currentColor;
1738
- stroke-width: 1.8;
1739
- stroke-linecap: round;
1740
- stroke-linejoin: round;
1741
- }
1742
-
1743
- :global(.atmo-comment-action[data-kind="like"][aria-pressed="true"] svg) {
1744
- fill: currentColor;
1745
- }
1746
- </style>