astro-atmosphere-comments 0.3.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,10 @@
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";
36
38
  import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
37
39
  import { escapeHtml } from "../lib/translate";
38
40
 
@@ -88,45 +90,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
88
90
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
89
91
 
90
92
  <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
91
- <form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
92
- <label for="atmo-handle">{t("signIn.heading")}</label>
93
- <p
94
- class="atmo__hint"
95
- set:html={th("signIn.anyAccount", {
96
- atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
97
- bluesky: link("https://bsky.app", "Bluesky"),
98
- })}
99
- />
100
- <div class="atmo__row">
101
- <span class="atmo__handle">
102
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
103
- <input
104
- id="atmo-handle"
105
- name="handle"
106
- type="text"
107
- inputmode="url"
108
- data-1p-ignore
109
- data-lpignore="true"
110
- data-bwignore
111
- data-form-type="other"
112
- autocapitalize="none"
113
- spellcheck="false"
114
- placeholder="alice.bsky.social"
115
- required
116
- />
117
- <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label={t("signIn.matchingAccounts")} data-handle-suggestions hidden></ul>
118
- </span>
119
- <input type="hidden" name="returnTo" value={returnTo} />
120
- <button type="submit" class="atmo__button atmo__button--sign-in">
121
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
122
- {t("signIn.button")}
123
- </button>
124
- </div>
125
- <p
126
- class="atmo__hint"
127
- set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
128
- />
129
- </form>
93
+ <SignInForm id="atmo" returnTo={returnTo} messages={messages} lang={lang} />
130
94
  {blueskyLink && (
131
95
  <p
132
96
  class="atmo__elsewhere"
@@ -156,57 +120,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
156
120
  </button>
157
121
  </div>
158
122
 
159
- <form class="atmo__composer" data-composer>
160
- <p class="atmo__context" data-context hidden>
161
- <span data-context-text></span>
162
- <button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
163
- </p>
164
- <label for="atmo-text" class="atmo__as">
165
- <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
166
- <span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
167
- </label>
168
- <div class="atmo__field">
169
- <textarea
170
- id="atmo-text"
171
- name="text"
172
- rows="4"
173
- required
174
- aria-autocomplete="list"
175
- aria-controls="atmo-suggestions"
176
- aria-expanded="false"></textarea>
177
- <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label={t("composer.mentions")} data-suggestions hidden></ul>
178
- </div>
179
- <figure class="atmo__preview" data-preview hidden>
180
- <figcaption>{t("composer.previewCaption")}</figcaption>
181
- <div class="atmo__preview-post">
182
- <span class="atmo__avatar" data-preview-avatar aria-hidden="true"></span>
183
- <div class="atmo__preview-body">
184
- <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
185
- <p class="atmo__preview-text" data-preview-text></p>
186
- <div class="atmo__quoted">
187
- <p class="atmo__preview-author">
188
- <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
189
- <strong data-quoted-name></strong> <span data-quoted-handle></span>
190
- </p>
191
- <p class="atmo__preview-text" data-quoted-text></p>
192
- <div class="atmo__card" data-card hidden>
193
- <img data-card-image alt="" hidden />
194
- <div>
195
- <strong data-card-title></strong>
196
- <span data-card-description></span>
197
- <span class="atmo__card-domain" data-card-domain></span>
198
- </div>
199
- </div>
200
- </div>
201
- </div>
202
- </div>
203
- </figure>
204
- <div class="atmo__row atmo__row--end">
205
- <span class="atmo__count" data-count aria-live="polite"></span>
206
- <button type="button" class="atmo__link-button" data-sign-out>{t("composer.signOut")}</button>
207
- <button type="submit" class="atmo__button" data-submit>{t("composer.postComment")}</button>
208
- </div>
209
- </form>
123
+ <Composer id="atmo" messages={messages} lang={lang} signOut />
210
124
  </div>
211
125
 
212
126
  <p class="atmo__status" data-status role="status" data-pagefind-ignore></p>
@@ -226,10 +140,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
226
140
 
227
141
  <script>
228
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";
229
145
 
230
- const MAX = 300;
231
- const graphemes = new Intl.Segmenter();
232
- const count = (text: string) => [...graphemes.segment(text.trim())].length;
233
146
 
234
147
  // The error and status codes the routes and the sign-in round trip send,
235
148
  // and the wording for each (in locales/*.json).
@@ -247,8 +160,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
247
160
  error: "messages.error",
248
161
  };
249
162
 
250
- type Posted = { uri: string; url: string; quote: boolean };
251
- type Suggestion = { handle: string; displayName?: string; avatar?: string };
252
163
 
253
164
  class AtmosphereComments extends HTMLElement {
254
165
  connectedCallback() {
@@ -434,7 +345,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
434
345
  const threadReady = loadThread();
435
346
 
436
347
  // A comment just posted, shown at once (Bluesky takes a moment to list it).
437
- const showPosted = (data: Posted & { text: string; createdAt: string; parent: string | null; author: Person }) => {
348
+ const showPosted = (data: Posted) => {
438
349
  if (data.quote) return;
439
350
  const parent = data.parent
440
351
  ? repliesList.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(data.parent)}"]`)
@@ -471,98 +382,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
471
382
 
472
383
  const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
473
384
 
474
- // Matching accounts under the handle box as the reader types, with their
475
- // avatars, so they can see they've found the right one. Picking one signs
476
- // in with it. Without JavaScript the box works as a plain text box.
477
- {
478
- const input = loginForm.querySelector<HTMLInputElement>("#atmo-handle")!;
479
- const list = loginForm.querySelector<HTMLUListElement>("[data-handle-suggestions]")!;
480
- let found: Suggestion[] = [];
481
- let active = -1;
482
- let latest = "";
483
- let timer: number | undefined;
484
- input.setAttribute("role", "combobox");
485
- input.setAttribute("aria-autocomplete", "list");
486
- input.setAttribute("aria-controls", list.id);
487
- input.setAttribute("aria-expanded", "false");
488
-
489
- const close = () => {
490
- list.hidden = true;
491
- list.replaceChildren();
492
- found = [];
493
- active = -1;
494
- input.setAttribute("aria-expanded", "false");
495
- input.removeAttribute("aria-activedescendant");
496
- };
497
- const highlight = (index: number) => {
498
- active = index;
499
- list.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
500
- if (index >= 0) input.setAttribute("aria-activedescendant", `${list.id}-option-${index}`);
501
- else input.removeAttribute("aria-activedescendant");
502
- };
503
- const choose = (index: number) => {
504
- const option = found[index];
505
- if (!option) return;
506
- input.value = option.handle;
507
- close();
508
- loginForm.requestSubmit();
509
- };
510
- const show = (actors: Suggestion[]) => {
511
- found = actors.slice(0, 5);
512
- if (!found.length || document.activeElement !== input) return close();
513
- list.replaceChildren(
514
- ...found.map((option, index) => {
515
- const item = make("li");
516
- item.id = `${list.id}-option-${index}`;
517
- item.setAttribute("role", "option");
518
- const avatar = make("span", "atmo__avatar atmo__avatar--small");
519
- avatar.setAttribute("aria-hidden", "true");
520
- setAvatar(avatar, option.displayName || option.handle, option.avatar);
521
- item.append(avatar, make("strong", "", option.displayName || option.handle), make("span", "", `@${option.handle}`));
522
- // mousedown keeps the focus in the handle box.
523
- item.addEventListener("mousedown", (event) => event.preventDefault());
524
- item.addEventListener("click", () => choose(index));
525
- return item;
526
- }),
527
- );
528
- list.hidden = false;
529
- input.setAttribute("aria-expanded", "true");
530
- // Nothing is picked until the reader chooses, so Enter still signs in
531
- // with exactly what they typed.
532
- highlight(-1);
533
- };
534
-
535
- input.addEventListener("input", () => {
536
- window.clearTimeout(timer);
537
- const query = input.value.trim().replace(/^@/, "");
538
- latest = query;
539
- if (query.length < 2 || query.startsWith("did:") || /\s/.test(query)) return close();
540
- timer = window.setTimeout(async () => {
541
- try {
542
- const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
543
- const data = (await response.json()) as { actors?: Suggestion[] };
544
- if (query === latest) show(data.actors ?? []);
545
- } catch {
546
- close();
547
- }
548
- }, 250);
549
- });
550
- input.addEventListener("keydown", (event) => {
551
- if (list.hidden) return;
552
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
553
- event.preventDefault();
554
- const step = event.key === "ArrowDown" ? 1 : -1;
555
- highlight(active < 0 && step < 0 ? found.length - 1 : (active + step + found.length) % found.length);
556
- } else if (event.key === "Enter" && active >= 0) {
557
- event.preventDefault();
558
- choose(active);
559
- } else if (event.key === "Escape") {
560
- event.preventDefault();
561
- close();
562
- }
563
- });
564
- input.addEventListener("blur", () => window.setTimeout(close, 150));
565
- }
385
+ enhanceHandleBox(loginForm, base);
566
386
 
567
387
  // An avatar picture, or the name's first letter if there isn't one.
568
388
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -584,196 +404,26 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
584
404
  Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
585
405
  );
586
406
 
587
- // Everything a comment box does: counting characters, @mention
588
- // suggestions and posting. Used by the main box and every reply box.
589
- 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 = (
590
411
  form: HTMLFormElement,
591
- behaviour: {
592
- extra: () => Record<string, unknown>;
593
- tell: (message: string) => void;
594
- onPosted: (data: Posted) => void;
595
- onInput?: () => void;
596
- },
597
- ) => {
598
- const textarea = form.querySelector("textarea")!;
599
- const counter = form.querySelector<HTMLElement>("[data-count]")!;
600
- const submit = form.querySelector<HTMLButtonElement>("[data-submit]")!;
601
- const suggestions = form.querySelector<HTMLUListElement>("[data-suggestions]")!;
602
-
603
- const updateCount = () => {
604
- const used = count(textarea.value);
605
- counter.textContent = used > MAX - 50 ? t("composer.charactersLeft", { count: MAX - used }) : "";
606
- counter.classList.toggle("is-over", used > MAX);
607
- };
608
-
609
- // @mention suggestions, as in the Bluesky app: typing @ and the start
610
- // of a handle or name lists matching accounts to choose from.
611
- let options: Suggestion[] = [];
612
- let active = -1;
613
- let lookup: number | undefined;
614
- let latestQuery = "";
615
- const mentionAtCaret = () => {
616
- const before = textarea.value.slice(0, textarea.selectionStart);
617
- const match = /(?:^|[\s(])@([a-zA-Z0-9.-]{1,64})$/.exec(before);
618
- return match ? { query: match[1], start: before.length - match[1].length - 1 } : null;
619
- };
620
- const closeSuggestions = () => {
621
- suggestions.hidden = true;
622
- suggestions.replaceChildren();
623
- options = [];
624
- active = -1;
625
- textarea.setAttribute("aria-expanded", "false");
626
- textarea.removeAttribute("aria-activedescendant");
627
- };
628
- const highlight = (index: number) => {
629
- active = index;
630
- suggestions.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
631
- if (index >= 0) textarea.setAttribute("aria-activedescendant", `${suggestions.id}-option-${index}`);
632
- };
633
- const choose = (index: number) => {
634
- const mention = mentionAtCaret();
635
- const option = options[index];
636
- if (!mention || !option) return closeSuggestions();
637
- const after = textarea.value.slice(textarea.selectionStart);
638
- textarea.value = `${textarea.value.slice(0, mention.start)}@${option.handle} ${after}`;
639
- const caret = mention.start + option.handle.length + 2;
640
- textarea.setSelectionRange(caret, caret);
641
- closeSuggestions();
642
- textarea.focus();
643
- updateCount();
644
- behaviour.onInput?.();
645
- };
646
- const showSuggestions = (found: Suggestion[]) => {
647
- options = found;
648
- if (!found.length) return closeSuggestions();
649
- suggestions.replaceChildren(
650
- ...found.map((option, index) => {
651
- const item = document.createElement("li");
652
- item.id = `${suggestions.id}-option-${index}`;
653
- item.setAttribute("role", "option");
654
- const avatar = document.createElement("span");
655
- avatar.className = "atmo__avatar atmo__avatar--small";
656
- setAvatar(avatar, option.displayName || option.handle, option.avatar);
657
- const name = Object.assign(document.createElement("strong"), { textContent: option.displayName || option.handle });
658
- const handle = Object.assign(document.createElement("span"), { textContent: `@${option.handle}` });
659
- item.append(avatar, name, handle);
660
- // mousedown keeps the focus in the text box.
661
- item.addEventListener("mousedown", (event) => event.preventDefault());
662
- item.addEventListener("click", () => choose(index));
663
- return item;
664
- }),
665
- );
666
- suggestions.hidden = false;
667
- textarea.setAttribute("aria-expanded", "true");
668
- highlight(0);
669
- };
670
- const lookUpMention = () => {
671
- window.clearTimeout(lookup);
672
- const mention = mentionAtCaret();
673
- if (!mention) return closeSuggestions();
674
- latestQuery = mention.query;
675
- lookup = window.setTimeout(async () => {
676
- const query = mention.query;
677
- try {
678
- const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
679
- const data = (await response.json()) as { actors?: Suggestion[] };
680
- if (query === latestQuery) showSuggestions(data.actors ?? []);
681
- } catch {
682
- closeSuggestions();
683
- }
684
- }, 200);
685
- };
686
-
687
- textarea.addEventListener("input", () => {
688
- updateCount();
689
- behaviour.onInput?.();
690
- lookUpMention();
691
- });
692
- textarea.addEventListener("keydown", (event) => {
693
- if (suggestions.hidden) return;
694
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
695
- event.preventDefault();
696
- const step = event.key === "ArrowDown" ? 1 : -1;
697
- highlight((active + step + options.length) % options.length);
698
- } else if (event.key === "Enter" || event.key === "Tab") {
699
- event.preventDefault();
700
- choose(Math.max(active, 0));
701
- } else if (event.key === "Escape") {
702
- event.preventDefault();
703
- closeSuggestions();
704
- }
705
- });
706
- textarea.addEventListener("blur", () => window.setTimeout(closeSuggestions, 150));
707
-
708
- form.addEventListener("submit", async (event) => {
709
- event.preventDefault();
710
- const text = textarea.value.trim();
711
- if (!text) return;
712
- if (count(text) > MAX) return behaviour.tell(message("too-long"));
713
- submit.disabled = true;
714
- behaviour.tell(t("composer.posting"));
715
- try {
716
- const response = await send("comment", { text, root: post, ...behaviour.extra() });
717
- const data = await response.json();
718
- if (!response.ok) return handleError(data.error, behaviour.tell);
719
- textarea.value = "";
720
- 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) => {
721
419
  behaviour.onPosted(data);
722
420
  showPosted(data);
723
421
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
724
- } catch {
725
- behaviour.tell(message("error"));
726
- } finally {
727
- submit.disabled = false;
728
- }
422
+ },
729
423
  });
730
424
 
731
- return { textarea, updateCount };
732
- };
733
-
734
- // The Quote preview: the reader's words as they type, with the
735
- // announcement quoted beneath, as Bluesky will show it.
425
+ // The Quote preview (lib/client/composer.ts).
736
426
  const preview = $<HTMLElement>("[data-preview]");
737
- const previewText = $<HTMLElement>("[data-preview-text]");
738
- let quotedLoaded = false;
739
- const updatePreviewText = () => {
740
- const words = main.textarea.value.trim();
741
- previewText.textContent = words || t("composer.previewEmpty");
742
- previewText.classList.toggle("is-placeholder", !words);
743
- };
744
- const loadQuoted = async () => {
745
- updatePreviewText();
746
- if (quotedLoaded) return;
747
- try {
748
- const response = await fetch(`${base}/post?uri=${encodeURIComponent(post)}`);
749
- if (!response.ok) return;
750
- const quoted = (await response.json()) as {
751
- author: { handle: string; displayName?: string; avatar?: string };
752
- text: string;
753
- external?: { uri: string; title: string; description: string; thumb?: string };
754
- };
755
- quotedLoaded = true;
756
- const quotedName = quoted.author.displayName || quoted.author.handle;
757
- $<HTMLElement>("[data-quoted-name]").textContent = quotedName;
758
- setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
759
- $<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
760
- $<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
761
- if (quoted.external) {
762
- $<HTMLElement>("[data-card]").hidden = false;
763
- $<HTMLElement>("[data-card-title]").textContent = quoted.external.title;
764
- $<HTMLElement>("[data-card-description]").textContent = quoted.external.description;
765
- $<HTMLElement>("[data-card-domain]").textContent = new URL(quoted.external.uri).hostname;
766
- if (quoted.external.thumb) {
767
- const img = $<HTMLImageElement>("[data-card-image]");
768
- img.onload = () => (img.hidden = false);
769
- img.onerror = () => (img.hidden = true);
770
- img.src = quoted.external.thumb;
771
- }
772
- }
773
- } catch {
774
- // The preview is a nicety: quoting still works without it.
775
- }
776
- };
777
427
 
778
428
  // The main box: a comment on the post, or (after Quote) a quote post.
779
429
  const setMainMode = (next: "comment" | "quote") => {
@@ -784,17 +434,18 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
784
434
  $<HTMLElement>("[data-verb]").textContent = t(quoting ? "composer.quote" : "composer.comment");
785
435
  $<HTMLElement>("[data-submit]").textContent = t(quoting ? "composer.postQuote" : "composer.postComment");
786
436
  preview.hidden = !quoting;
787
- if (quoting) void loadQuoted();
437
+ if (quoting) void quote.load(post);
788
438
  };
789
- const main = setUpComposer(composer, {
439
+ const main = composerFor(composer, {
790
440
  extra: () => (mainMode === "quote" ? { quote: true } : {}),
791
441
  tell: say,
792
442
  onPosted: (data) => {
793
443
  setMainMode("comment");
794
444
  postedMessage(status, data);
795
445
  },
796
- onInput: updatePreviewText,
446
+ onInput: () => quote.update(),
797
447
  });
448
+ const quote = quotePreview(composer, main.textarea, composerContext);
798
449
  $<HTMLButtonElement>("[data-cancel-context]").addEventListener("click", () => {
799
450
  setMainMode("comment");
800
451
  main.textarea.focus();
@@ -825,7 +476,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
825
476
  form.querySelector<HTMLElement>("[data-context]")!.hidden = false;
826
477
  form.querySelector("[data-context-text]")!.textContent = t("composer.replyingTo", { name: author });
827
478
  form.querySelector("[data-verb]")!.textContent = t("comment.reply");
828
- form.querySelector("[data-submit]")!.textContent = "Post reply";
479
+ form.querySelector("[data-submit]")!.textContent = t("composer.postReply");
829
480
  form.querySelector("[data-handle]")!.textContent = me.handle ? `@${me.handle}` : "";
830
481
  setAvatar(form.querySelector<HTMLElement>("[data-me-avatar]")!, me.displayName || me.handle || "?", me.avatar);
831
482
  const replyStatus = scoped(Object.assign(document.createElement("p"), { className: "atmo__status" }));
@@ -834,7 +485,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
834
485
  (comment.querySelector("article") ?? comment).append(holder);
835
486
  const close = () => holder.remove();
836
487
  form.querySelector("[data-cancel-context]")!.addEventListener("click", close);
837
- setUpComposer(form, {
488
+ composerFor(form, {
838
489
  extra: () => ({ parent: comment.dataset.atmosphereUri }),
839
490
  tell: (message) => (replyStatus.textContent = message),
840
491
  // The reply appears under the comment (the page adds it), so the box goes.
@@ -1126,649 +777,3 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1126
777
 
1127
778
  customElements.define("atmosphere-comments", AtmosphereComments);
1128
779
  </script>
1129
-
1130
- <style>
1131
- .atmo {
1132
- display: block;
1133
- color: var(--atmo-text, currentColor);
1134
- }
1135
-
1136
- /* Only the component's own parts wait for sign-in to be checked; the
1137
- site's slot content shows straight away. */
1138
- .atmo.is-loading > :is([data-view], [data-reactions], [data-replies]) {
1139
- visibility: hidden;
1140
- }
1141
-
1142
- .atmo [hidden] {
1143
- display: none !important;
1144
- }
1145
-
1146
- /* The Atmosphere Account logo inside the handle box, on the left, and at
1147
- the start of the sign-in button. It is single-colour, so it is drawn in
1148
- the muted text colour in the box and the button's text colour on it. */
1149
- .atmo__handle {
1150
- position: relative;
1151
- display: flex;
1152
- flex: 1 1 220px;
1153
- min-width: 0;
1154
- }
1155
-
1156
- /* Matching accounts sit under the handle box, as wide as it. */
1157
- .atmo__handle .atmo__suggestions {
1158
- width: 100%;
1159
- }
1160
-
1161
- .atmo__handle .atmo__logo {
1162
- position: absolute;
1163
- top: 50%;
1164
- left: 11px;
1165
- width: 20px;
1166
- height: 20px;
1167
- transform: translateY(-50%);
1168
- background-color: var(--atmo-muted, currentColor);
1169
- mask: var(--atmo-logo) center / contain no-repeat;
1170
- pointer-events: none;
1171
- }
1172
-
1173
- .atmo .atmo__handle input[type="text"] {
1174
- padding-left: 40px;
1175
- }
1176
-
1177
- .atmo__button--sign-in {
1178
- display: inline-flex;
1179
- align-items: center;
1180
- gap: 8px;
1181
- }
1182
-
1183
- .atmo__button--sign-in .atmo__logo {
1184
- flex: none;
1185
- width: 20px;
1186
- height: 20px;
1187
- background-color: currentColor;
1188
- mask: var(--atmo-logo) center / contain no-repeat;
1189
- }
1190
-
1191
- /* The comment box when it sits under a comment being replied to. */
1192
- .atmo--inline {
1193
- margin: 4px 0 6px;
1194
- }
1195
-
1196
- .atmo--inline .atmo__composer {
1197
- margin-top: 10px;
1198
- }
1199
-
1200
- .atmo__elsewhere {
1201
- max-width: 560px;
1202
- margin: 14px 0 0;
1203
- font-size: 0.9em;
1204
- }
1205
-
1206
- .atmo__elsewhere :global(.atmo__elsewhere-link) {
1207
- font-weight: 700;
1208
- }
1209
-
1210
- .atmo__login,
1211
- .atmo__composer {
1212
- display: grid;
1213
- gap: 8px;
1214
- max-width: 560px;
1215
- margin-top: 14px;
1216
- }
1217
-
1218
- .atmo label {
1219
- font-weight: 700;
1220
- }
1221
-
1222
- .atmo__row {
1223
- display: flex;
1224
- flex-wrap: wrap;
1225
- align-items: center;
1226
- gap: 10px;
1227
- }
1228
-
1229
- .atmo__row--end {
1230
- justify-content: flex-end;
1231
- }
1232
-
1233
- .atmo input[type="text"],
1234
- .atmo textarea {
1235
- flex: 1 1 220px;
1236
- min-width: 0;
1237
- padding: 10px 12px;
1238
- border: 1px solid var(--atmo-border, #8886);
1239
- border-radius: var(--atmo-radius, 10px);
1240
- background: var(--atmo-field-bg, transparent);
1241
- color: inherit;
1242
- font: inherit;
1243
- }
1244
-
1245
- .atmo textarea {
1246
- resize: vertical;
1247
- }
1248
-
1249
- .atmo__button {
1250
- padding: 10px 16px;
1251
- border: 0;
1252
- border-radius: var(--atmo-radius, 10px);
1253
- background: var(--atmo-accent, #1185fe);
1254
- color: var(--atmo-on-accent, #fff);
1255
- font: inherit;
1256
- font-weight: 700;
1257
- cursor: pointer;
1258
- }
1259
-
1260
- .atmo__button:disabled {
1261
- opacity: 0.6;
1262
- cursor: progress;
1263
- }
1264
-
1265
- .atmo__link-button {
1266
- padding: 0;
1267
- border: 0;
1268
- background: none;
1269
- color: var(--atmo-muted, inherit);
1270
- font: inherit;
1271
- text-decoration: underline;
1272
- cursor: pointer;
1273
- }
1274
-
1275
- /* Like, Repost and Quote: outlined pills that fill in once done. */
1276
- .atmo__actions {
1277
- display: flex;
1278
- flex-wrap: wrap;
1279
- gap: 8px;
1280
- }
1281
-
1282
- .atmo__action {
1283
- display: inline-flex;
1284
- align-items: center;
1285
- gap: 6px;
1286
- padding: 7px 14px 7px 10px;
1287
- border: 1px solid var(--atmo-border, #8886);
1288
- border-radius: 999px;
1289
- background: none;
1290
- color: inherit;
1291
- font: inherit;
1292
- font-weight: 600;
1293
- cursor: pointer;
1294
- }
1295
-
1296
- .atmo__action:hover,
1297
- .atmo__action:focus-visible {
1298
- border-color: var(--atmo-accent, #1185fe);
1299
- color: var(--atmo-accent, #1185fe);
1300
- }
1301
-
1302
- .atmo__action svg {
1303
- width: 18px;
1304
- height: 18px;
1305
- fill: none;
1306
- stroke: currentColor;
1307
- stroke-width: 1.8;
1308
- stroke-linecap: round;
1309
- stroke-linejoin: round;
1310
- }
1311
-
1312
- .atmo__action[aria-pressed="true"] {
1313
- border-color: var(--atmo-accent, #1185fe);
1314
- color: var(--atmo-accent, #1185fe);
1315
- }
1316
-
1317
- .atmo__action[data-react="like"][aria-pressed="true"] svg {
1318
- fill: currentColor;
1319
- }
1320
-
1321
- .atmo__action [data-label-on],
1322
- .atmo__action[aria-pressed="true"] [data-label-off] {
1323
- display: none;
1324
- }
1325
-
1326
- .atmo__action[aria-pressed="true"] [data-label-on] {
1327
- display: inline;
1328
- }
1329
-
1330
- .atmo__context {
1331
- display: flex;
1332
- flex-wrap: wrap;
1333
- gap: 10px;
1334
- margin: 0;
1335
- color: var(--atmo-muted, inherit);
1336
- font-size: 0.9em;
1337
- }
1338
-
1339
- .atmo__hint,
1340
- .atmo__count,
1341
- .atmo__status {
1342
- margin: 0;
1343
- color: var(--atmo-muted, inherit);
1344
- font-size: 0.9em;
1345
- }
1346
-
1347
- .atmo__count {
1348
- margin-right: auto;
1349
- }
1350
-
1351
- .atmo__count.is-over {
1352
- color: var(--atmo-error, #d33);
1353
- }
1354
-
1355
- .atmo__status:not(:empty) {
1356
- margin-top: 10px;
1357
- }
1358
-
1359
- .atmo__credit {
1360
- margin: 14px 0 0;
1361
- color: var(--atmo-muted, inherit);
1362
- font-size: 0.75em;
1363
- opacity: 0.8;
1364
- }
1365
-
1366
- .atmo__credit :global(a) {
1367
- color: inherit;
1368
- }
1369
-
1370
- /* @mention suggestions, under the text box. */
1371
- .atmo__field {
1372
- position: relative;
1373
- display: grid;
1374
- }
1375
-
1376
- .atmo__suggestions {
1377
- position: absolute;
1378
- z-index: 5;
1379
- top: calc(100% + 4px);
1380
- left: 0;
1381
- width: min(100%, 360px);
1382
- margin: 0;
1383
- padding: 4px;
1384
- list-style: none;
1385
- border: 1px solid var(--atmo-border, #8886);
1386
- border-radius: var(--atmo-radius, 10px);
1387
- background: var(--atmo-menu-bg, Canvas);
1388
- box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
1389
- }
1390
-
1391
- .atmo__suggestions :global(li) {
1392
- display: flex;
1393
- align-items: center;
1394
- gap: 8px;
1395
- padding: 7px 9px;
1396
- border-radius: calc(var(--atmo-radius, 10px) - 4px);
1397
- cursor: pointer;
1398
- }
1399
-
1400
- .atmo__suggestions :global(li[aria-selected="true"]),
1401
- .atmo__suggestions :global(li:hover) {
1402
- background: color-mix(in srgb, var(--atmo-accent, #1185fe) 14%, transparent);
1403
- }
1404
-
1405
- .atmo__suggestions :global(li span:last-child) {
1406
- overflow: hidden;
1407
- color: var(--atmo-muted, inherit);
1408
- text-overflow: ellipsis;
1409
- white-space: nowrap;
1410
- }
1411
-
1412
- .atmo__suggestions :global(.atmo__avatar) {
1413
- display: grid;
1414
- flex: none;
1415
- place-items: center;
1416
- width: 26px;
1417
- height: 26px;
1418
- overflow: hidden;
1419
- border-radius: 50%;
1420
- background: var(--atmo-border, #8886);
1421
- font-size: 11px;
1422
- font-weight: 700;
1423
- }
1424
-
1425
- .atmo__suggestions :global(.atmo__avatar img) {
1426
- width: 100%;
1427
- height: 100%;
1428
- object-fit: cover;
1429
- }
1430
-
1431
- /* The Quote preview: a post card with the quoted post nested inside. */
1432
- .atmo__preview {
1433
- display: grid;
1434
- gap: 6px;
1435
- margin: 4px 0 0;
1436
- }
1437
-
1438
- .atmo__preview figcaption {
1439
- color: var(--atmo-muted, inherit);
1440
- font-size: 0.85em;
1441
- font-weight: 700;
1442
- }
1443
-
1444
- .atmo__preview-post,
1445
- .atmo__quoted {
1446
- padding: 12px 14px;
1447
- border: 1px solid var(--atmo-border, #8886);
1448
- border-radius: var(--atmo-radius, 10px);
1449
- }
1450
-
1451
- /* As on Bluesky: the avatar beside the post, the post's parts stacked. */
1452
- .atmo__preview-post {
1453
- display: grid;
1454
- grid-template-columns: auto minmax(0, 1fr);
1455
- gap: 12px;
1456
- }
1457
-
1458
- .atmo__preview-body,
1459
- .atmo__quoted {
1460
- display: grid;
1461
- gap: 6px;
1462
- min-width: 0;
1463
- }
1464
-
1465
- .atmo__avatar {
1466
- display: grid;
1467
- place-items: center;
1468
- width: 42px;
1469
- height: 42px;
1470
- overflow: hidden;
1471
- border-radius: 50%;
1472
- background: var(--atmo-border, #8886);
1473
- color: var(--atmo-text, inherit);
1474
- font-weight: 700;
1475
- }
1476
-
1477
- /* "Comment as @handle", with the reader's avatar as a reminder of which
1478
- account they're signed in with. */
1479
- .atmo__as {
1480
- display: flex;
1481
- align-items: center;
1482
- gap: 8px;
1483
- }
1484
-
1485
- .atmo__avatar--me {
1486
- width: 28px;
1487
- height: 28px;
1488
- font-size: 12px;
1489
- }
1490
-
1491
- .atmo__avatar--small {
1492
- display: inline-grid;
1493
- width: 18px;
1494
- height: 18px;
1495
- margin-right: 4px;
1496
- font-size: 10px;
1497
- vertical-align: -4px;
1498
- }
1499
-
1500
- /* Added by the script, so outside Astro's scoped styles. */
1501
- .atmo__avatar :global(img) {
1502
- width: 100%;
1503
- height: 100%;
1504
- object-fit: cover;
1505
- }
1506
-
1507
- .atmo__preview-post p,
1508
- .atmo__card span {
1509
- margin: 0;
1510
- }
1511
-
1512
- .atmo__preview-author span,
1513
- .atmo__card-domain {
1514
- color: var(--atmo-muted, inherit);
1515
- }
1516
-
1517
- .atmo__preview-text {
1518
- white-space: pre-wrap;
1519
- overflow-wrap: anywhere;
1520
- }
1521
-
1522
- .atmo__preview-text.is-placeholder {
1523
- color: var(--atmo-muted, inherit);
1524
- font-style: italic;
1525
- }
1526
-
1527
- .atmo__quoted {
1528
- font-size: 0.92em;
1529
- }
1530
-
1531
- .atmo__card {
1532
- overflow: hidden;
1533
- border: 1px solid var(--atmo-border, #8886);
1534
- border-radius: var(--atmo-radius, 10px);
1535
- }
1536
-
1537
- .atmo__card img {
1538
- display: block;
1539
- width: 100%;
1540
- aspect-ratio: 1.91 / 1;
1541
- object-fit: cover;
1542
- }
1543
-
1544
- .atmo__card > div {
1545
- display: grid;
1546
- gap: 2px;
1547
- padding: 10px 12px;
1548
- }
1549
-
1550
- .atmo__card [data-card-description] {
1551
- display: -webkit-box;
1552
- overflow: hidden;
1553
- -webkit-box-orient: vertical;
1554
- -webkit-line-clamp: 2;
1555
- }
1556
-
1557
- .atmo__card-domain {
1558
- font-size: 0.85em;
1559
- }
1560
-
1561
- /* The likes and reposts, and the comments. These are drawn by the script,
1562
- and wrapped in :where() so they carry no weight: any rule a site writes
1563
- for these classes wins. */
1564
- :global(:where(.atmo-reactions)) {
1565
- display: flex;
1566
- flex-wrap: wrap;
1567
- gap: 8px 32px;
1568
- margin: 0 0 18px;
1569
- }
1570
-
1571
- :global(:where(.atmo-reactions__count)) {
1572
- margin: 0 0 8px;
1573
- font-weight: 700;
1574
- }
1575
-
1576
- :global(:where(.atmo-facepile)) {
1577
- display: flex;
1578
- flex-wrap: wrap;
1579
- margin: 0;
1580
- padding: 0 0 0 8px;
1581
- list-style: none;
1582
- }
1583
-
1584
- :global(:where(.atmo-facepile li)) {
1585
- margin-left: -8px;
1586
- }
1587
-
1588
- :global(:where(.atmo-facepile a)) {
1589
- display: block;
1590
- border-radius: 50%;
1591
- }
1592
-
1593
- :global(:where(.atmo-facepile__avatar, .atmo-facepile__more)) {
1594
- display: grid;
1595
- place-items: center;
1596
- width: 36px;
1597
- height: 36px;
1598
- border: 2px solid var(--atmo-ring, Canvas);
1599
- border-radius: 50%;
1600
- object-fit: cover;
1601
- background: var(--atmo-field-bg, #8882);
1602
- font-size: 12px;
1603
- }
1604
-
1605
- :global(:where(.atmo-facepile__more)) {
1606
- width: auto;
1607
- min-width: 36px;
1608
- padding: 0 8px;
1609
- border-radius: 18px;
1610
- }
1611
-
1612
- :global(:where(.atmo-avatar-initial)) {
1613
- display: inline-grid;
1614
- place-items: center;
1615
- color: #fff;
1616
- font-weight: 800;
1617
- background: var(--atmo-accent, #1185fe);
1618
- }
1619
-
1620
- :global(:where(.atmo-comments)) {
1621
- display: grid;
1622
- gap: 12px;
1623
- margin: 24px 0 0;
1624
- padding: 0;
1625
- list-style: none;
1626
- }
1627
-
1628
- :global(:where(.atmo-comments:empty)) {
1629
- display: none;
1630
- }
1631
-
1632
- :global(:where(.atmo-comment article)) {
1633
- padding: 16px 18px;
1634
- border: 1px solid var(--atmo-border, #8886);
1635
- border-radius: 16px;
1636
- background: var(--atmo-card-bg, transparent);
1637
- }
1638
-
1639
- :global(:where(.atmo-comment--author > article)) {
1640
- border-color: var(--atmo-accent, #1185fe);
1641
- }
1642
-
1643
- /* Replies step in under the comment they answer (up to three levels). */
1644
- :global(:where(.atmo-comment--nested)) {
1645
- margin-left: calc((var(--depth, 1) - 1) * 24px);
1646
- padding-left: 22px;
1647
- border-left: 2px solid var(--atmo-border, #8886);
1648
- }
1649
-
1650
- :global(:where(.atmo-comment__header)) {
1651
- display: flex;
1652
- align-items: center;
1653
- gap: 12px;
1654
- }
1655
-
1656
- :global(:where(.atmo-comment__avatar)) {
1657
- flex: none;
1658
- width: 40px;
1659
- height: 40px;
1660
- border-radius: 50%;
1661
- object-fit: cover;
1662
- }
1663
-
1664
- :global(:where(.atmo-comment__who)) {
1665
- display: flex;
1666
- flex-wrap: wrap;
1667
- align-items: center;
1668
- gap: 8px;
1669
- }
1670
-
1671
- :global(:where(.atmo-comment__author)) {
1672
- color: inherit;
1673
- font-weight: 750;
1674
- text-decoration: none;
1675
- }
1676
-
1677
- :global(:where(.atmo-comment__badge)) {
1678
- padding: 2px 7px;
1679
- border: 1px solid var(--atmo-accent, #1185fe);
1680
- border-radius: 999px;
1681
- color: var(--atmo-accent, #1185fe);
1682
- font-size: 11px;
1683
- font-weight: 800;
1684
- letter-spacing: 0.04em;
1685
- text-transform: uppercase;
1686
- }
1687
-
1688
- :global(:where(.atmo-comment__date)) {
1689
- color: var(--atmo-muted, inherit);
1690
- font-size: 13px;
1691
- text-decoration: none;
1692
- }
1693
-
1694
- :global(:where(.atmo-comment__body)) {
1695
- margin-top: 10px;
1696
- line-height: 1.65;
1697
- overflow-wrap: anywhere;
1698
- white-space: pre-line;
1699
- }
1700
-
1701
- :global(:where(.atmo-comment__body p)) {
1702
- margin: 0;
1703
- }
1704
-
1705
- /* Reply, Like and Repost under each comment (outside this element, so
1706
- unscoped). */
1707
- :global(.atmo-comment-actions) {
1708
- display: flex;
1709
- flex-wrap: wrap;
1710
- gap: 18px;
1711
- margin-top: 8px;
1712
- }
1713
-
1714
- :global(.atmo-comment-delete) {
1715
- display: flex;
1716
- flex-wrap: wrap;
1717
- align-items: center;
1718
- gap: 6px 12px;
1719
- margin-top: 8px;
1720
- font-size: 0.9em;
1721
- }
1722
-
1723
- :global(.atmo-comment-delete form) {
1724
- display: contents;
1725
- }
1726
-
1727
- :global(.atmo-comment-delete__text) {
1728
- color: var(--atmo-muted, inherit);
1729
- }
1730
-
1731
-
1732
- :global(.atmo-comment-action) {
1733
- display: inline-flex;
1734
- align-items: center;
1735
- gap: 5px;
1736
- padding: 0;
1737
- border: 0;
1738
- background: none;
1739
- color: var(--atmo-muted, inherit);
1740
- font: inherit;
1741
- font-size: 0.85em;
1742
- font-weight: 600;
1743
- cursor: pointer;
1744
- }
1745
-
1746
- :global(.atmo-comment-action--delete) {
1747
- margin-left: auto;
1748
- }
1749
-
1750
- :global(.atmo-comment-action--danger) {
1751
- color: var(--atmo-danger, #c0392b);
1752
- font-weight: 700;
1753
- }
1754
-
1755
- :global(.atmo-comment-action:hover),
1756
- :global(.atmo-comment-action:focus-visible),
1757
- :global(.atmo-comment-action[aria-pressed="true"]) {
1758
- color: var(--atmo-accent, #1185fe);
1759
- }
1760
-
1761
- :global(.atmo-comment-action svg) {
1762
- width: 16px;
1763
- height: 16px;
1764
- fill: none;
1765
- stroke: currentColor;
1766
- stroke-width: 1.8;
1767
- stroke-linecap: round;
1768
- stroke-linejoin: round;
1769
- }
1770
-
1771
- :global(.atmo-comment-action[data-kind="like"][aria-pressed="true"] svg) {
1772
- fill: currentColor;
1773
- }
1774
- </style>