astro-atmosphere-comments 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ ---
2
+ // Subscribe to the site's Standard.site publication, with a ⌄ menu of more
3
+ // ways to follow it (Copy link, the feed, Bluesky, Standard Reader, Leaflet
4
+ // and any links of the site's own). Shown only with the integration's
5
+ // `standardSite` option, and only with JavaScript (the menu needs it).
6
+ //
7
+ // Placed anywhere, such as a blog's list of posts. The Subscribe button
8
+ // itself is worked by AtmosphereReactions, which offers signing in, so put
9
+ // that in the layout too. AtmosphereComments uses this component for the one
10
+ // under each post, and works that button itself.
11
+ //
12
+ // Props:
13
+ // align which edge the menu lines up with: "start" (default) or "end"
14
+ // (for a button near the right-hand edge, so the menu opens leftwards)
15
+ // copyUrl what Copy link copies (default: the site's address)
16
+ // links more menu items, after the built-in ones: [{ label, href }]
17
+ // bluesky adds "View on Bluesky", going here (under a post: its thread)
18
+ //
19
+ // The menu's note ends by suggesting following the publication's account on
20
+ // Bluesky, with AtmosphereFollow: a link to its profile, which AtmosphereReactions
21
+ // turns into a Follow / Following button for signed-in readers.
22
+ // locale, messages as for AtmosphereComments
23
+ import logo from "../assets/union.svg?url";
24
+ import settings from "../lib/settings";
25
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
26
+ import { escapeHtml } from "../lib/translate";
27
+ import { profileFor } from "../lib/profile";
28
+ import AtmosphereFollow from "./AtmosphereFollow.astro";
29
+ import "./atmosphere.css";
30
+
31
+ interface Props {
32
+ align?: "start" | "end";
33
+ copyUrl?: string;
34
+ links?: { label: string; href: string }[];
35
+ bluesky?: string;
36
+ locale?: string;
37
+ messages?: Record<string, string>;
38
+ }
39
+
40
+ const { align = "start", copyUrl = settings.site, links = [] } = Astro.props;
41
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
42
+ const messages = messagesFor(lang, { ...settings.messages, ...Astro.props.messages });
43
+ const t = (key: string) => translate(messages, lang, key);
44
+ const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
45
+ const link = (href: string, text: string) => `<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(text)}</a>`;
46
+
47
+ const publication = settings.standardSite?.publication ?? "";
48
+ const feed = settings.standardSite?.feed;
49
+ // at://<did>/site.standard.publication/<rkey>
50
+ const [, , did = "", , rkey = ""] = publication.split("/");
51
+ const bluesky = Astro.props.bluesky ?? "";
52
+ // The publication's account, by handle, for the note and the Follow link
53
+ // (looked up when the page is built; without it, the note leaves that part out).
54
+ const account = did ? await profileFor(did).catch(() => null) : null;
55
+ const accountHandle = account && account.handle !== did ? account.handle : "";
56
+ ---
57
+
58
+ {publication && (
59
+ <atmosphere-subscribe class:list={["atmo-subscribe", align === "end" && "atmo-subscribe--end"]} data-subscribe-panel data-pagefind-ignore hidden>
60
+ <button type="button" class="atmo__action atmo-subscribe__button" data-atmosphere-subscribe hidden>
61
+ <span class="atmo-subscribe__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
62
+ <span data-atmosphere-subscribe-label>{t("subscribe.subscribe")}</span>
63
+ </button>
64
+ <button type="button" class="atmo__action atmo-subscribe__more" aria-label={t("subscribe.more")} title={t("subscribe.more")} aria-expanded="false" data-subscribe-more>
65
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
66
+ </button>
67
+ <div class="atmo-subscribe__menu" data-subscribe-menu hidden>
68
+ <div class="atmo-subscribe__note">
69
+ <p
70
+ set:html={th(feed ? "subscribe.panelTextFeed" : "subscribe.panelText", {
71
+ leaflet: link("https://leaflet.pub", "Leaflet"),
72
+ standardReader: link("https://standard-reader.app", "Standard Reader"),
73
+ }) + (accountHandle ? ` ${escapeHtml(translate(messages, lang, "subscribe.followNote", { handle: `@${accountHandle}` }))}` : "")}
74
+ />
75
+ {accountHandle && <AtmosphereFollow account={did} label={t("subscribe.follow")} class="atmo-subscribe__follow" locale={lang} messages={messages} />}
76
+ </div>
77
+ <button type="button" data-subscribe-copy={copyUrl} data-copied={t("subscribe.copied")}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></svg><span data-copy-label>{t("subscribe.copyLink")}</span></button>
78
+ {feed && <a href={feed}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 11a8 8 0 0 1 8 8M5 5a14 14 0 0 1 14 14" /><circle cx="6" cy="18" r="1" /></svg><span>{t("subscribe.feed")}</span></a>}
79
+ <a href={bluesky || undefined} hidden={!bluesky} data-subscribe-bluesky target="_blank" rel="noopener"><svg class="atmo-subscribe__filled" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" /></svg><span>{t("subscribe.viewBluesky")}</span></a>
80
+ <a href={`https://standard-reader.app/p/${did}/${rkey}`} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{t("subscribe.standardReader")}</span></a>
81
+ <a href={`https://leaflet.pub/lish/${did}/${rkey}`} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{t("subscribe.leaflet")}</span></a>
82
+ {links.map(({ label, href }) => (
83
+ <a href={href} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{label}</span></a>
84
+ ))}
85
+ </div>
86
+ </atmosphere-subscribe>
87
+ )}
88
+
89
+ <script>
90
+ // The ⌄ menu: opens and closes (click, a click elsewhere, Escape) and Copy
91
+ // link. Shows itself once this runs, except under a post, where
92
+ // AtmosphereComments shows it when the comments are ready.
93
+ class AtmosphereSubscribe extends HTMLElement {
94
+ connectedCallback() {
95
+ const more = this.querySelector<HTMLButtonElement>("[data-subscribe-more]");
96
+ const menu = this.querySelector<HTMLElement>("[data-subscribe-menu]");
97
+ if (!more || !menu || this.dataset.ready !== undefined) return;
98
+ this.dataset.ready = "";
99
+ if (!this.closest("atmosphere-comments")) this.hidden = false;
100
+ const setMenu = (open: boolean) => {
101
+ menu.hidden = !open;
102
+ more.setAttribute("aria-expanded", String(open));
103
+ };
104
+ more.addEventListener("click", () => {
105
+ setMenu(menu.hidden);
106
+ if (!menu.hidden) menu.querySelector<HTMLElement>("[data-subscribe-copy]")?.focus();
107
+ });
108
+ document.addEventListener("click", (event) => {
109
+ if (!this.contains(event.target as Node)) setMenu(false);
110
+ });
111
+ this.addEventListener("keydown", (event) => {
112
+ if (event.key === "Escape" && !menu.hidden) {
113
+ setMenu(false);
114
+ more.focus();
115
+ }
116
+ });
117
+ const copy = menu.querySelector<HTMLButtonElement>("[data-subscribe-copy]")!;
118
+ const copyText = copy.querySelector<HTMLElement>("[data-copy-label]")!;
119
+ const copyLabel = copyText.textContent;
120
+ copy.addEventListener("click", async () => {
121
+ try {
122
+ await navigator.clipboard.writeText(copy.dataset.subscribeCopy!);
123
+ copyText.textContent = copy.dataset.copied!;
124
+ window.setTimeout(() => {
125
+ copyText.textContent = copyLabel;
126
+ setMenu(false);
127
+ }, 1200);
128
+ } catch {
129
+ // Clipboard refused: the button stays as it was.
130
+ }
131
+ });
132
+ }
133
+ }
134
+ if (!customElements.get("atmosphere-subscribe")) customElements.define("atmosphere-subscribe", AtmosphereSubscribe);
135
+ </script>
@@ -12,6 +12,7 @@
12
12
  // cancel show a Cancel button that closes an overlay (default false)
13
13
  import { translate, translateHtml } from "../lib/i18n";
14
14
  import { escapeHtml } from "../lib/translate";
15
+ import PostPreview from "./PostPreview.astro";
15
16
 
16
17
  interface Props {
17
18
  id: string;
@@ -31,6 +32,10 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
31
32
  <span data-context-text></span>
32
33
  <button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
33
34
  </p>
35
+ <!-- The post being replied to, when it won't be on this page (AtmosphereReactions). -->
36
+ <div class="atmo__quoted atmo__parent" data-parent hidden>
37
+ <PostPreview />
38
+ </div>
34
39
  <label for={`${id}-text`} class="atmo__as">
35
40
  <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
36
41
  <span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
@@ -53,23 +58,8 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
53
58
  <div class="atmo__preview-body">
54
59
  <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
55
60
  <p class="atmo__preview-text" data-preview-text></p>
56
- <div class="atmo__quoted">
57
- <p class="atmo__preview-author">
58
- <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
59
- <strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
60
- </p>
61
- <p class="atmo__preview-text" data-quoted-text></p>
62
- <div class="atmo__card" data-card hidden>
63
- <img data-card-image alt="" hidden />
64
- <div>
65
- <strong data-card-title></strong>
66
- <span data-card-description></span>
67
- <span class="atmo__card-domain">
68
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
69
- <span data-card-domain></span>
70
- </span>
71
- </div>
72
- </div>
61
+ <div class="atmo__quoted" data-quoted-post>
62
+ <PostPreview />
73
63
  </div>
74
64
  </div>
75
65
  </div>
@@ -0,0 +1,24 @@
1
+ ---
2
+ // A Bluesky post as the composer shows it: author (avatar, name, handle,
3
+ // age), its words and its link card. Empty here, filled in by showPost() in
4
+ // lib/client/composer.ts. Used inside the Quote preview (the post being
5
+ // quoted) and above the box when replying from AtmosphereReactions (the post
6
+ // being replied to); not meant to be used on its own.
7
+ ---
8
+
9
+ <p class="atmo__preview-author">
10
+ <span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
11
+ <strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
12
+ </p>
13
+ <p class="atmo__preview-text" data-quoted-text></p>
14
+ <div class="atmo__card" data-card hidden>
15
+ <img data-card-image alt="" hidden />
16
+ <div>
17
+ <strong data-card-title></strong>
18
+ <span data-card-description></span>
19
+ <span class="atmo__card-domain">
20
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
21
+ <span data-card-domain></span>
22
+ </span>
23
+ </div>
24
+ </div>
@@ -30,7 +30,14 @@ interface Props {
30
30
 
31
31
  const { id, returnTo, messages, lang, heading, permissions = [] } = Astro.props;
32
32
  const { policyUri } = settings;
33
- const t = (key: string) => translate(messages, lang, key);
33
+ const t = (key: string, vars?: Record<string, string>) => translate(messages, lang, key, vars);
34
+ // The site's name in "… can use it only for what you approved", e.g. saltedlolly.com.
35
+ let siteName = "";
36
+ try {
37
+ siteName = new URL(settings.site).hostname;
38
+ } catch {
39
+ siteName = "";
40
+ }
34
41
  const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
35
42
  // Links lead off the page, so they open in a new tab.
36
43
  const link = (href: string, text: string) =>
@@ -40,12 +47,13 @@ const link = (href: string, text: string) =>
40
47
  <form class="atmo__login" id={`${id}-login`} data-login-form>
41
48
  <label for={`${id}-handle`}>{heading ?? t("signIn.heading")}</label>
42
49
  <p
43
- class="atmo__hint"
50
+ class="atmo__hint atmo__hint--accounts"
44
51
  set:html={th("signIn.anyAccount", {
45
52
  atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
46
53
  bluesky: link("https://bsky.app", "Bluesky"),
47
- createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")),
48
- })}
54
+ }) +
55
+ "<br>" +
56
+ th("signIn.noAccount", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
49
57
  />
50
58
  <div class="atmo__row">
51
59
  <span class="atmo__handle">
@@ -84,9 +92,8 @@ const link = (href: string, text: string) =>
84
92
  {/* The detail, for anyone who wants it. */}
85
93
  <details class="atmo__details">
86
94
  <summary>{t("signIn.more")}</summary>
87
- <p class="atmo__hint">{t("signIn.privacy")}</p>
88
95
  <p class="atmo__hint">
89
- {t("signIn.access")}
96
+ {t("signIn.privacy", { site: siteName || t("signIn.thisSite") })}
90
97
  {policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
91
98
  </p>
92
99
  </details>
@@ -108,6 +115,11 @@ const link = (href: string, text: string) =>
108
115
  font-weight: 700;
109
116
  }
110
117
 
118
+ /* The two lines about accounts, above the box: a little more room. */
119
+ .atmo__hint.atmo__hint--accounts {
120
+ line-height: 1.7;
121
+ }
122
+
111
123
  .atmo__details summary {
112
124
  width: fit-content;
113
125
  color: var(--atmo-muted, inherit);
@@ -123,6 +135,8 @@ const link = (href: string, text: string) =>
123
135
  margin: 0;
124
136
  color: var(--atmo-muted, inherit);
125
137
  font-size: 0.9em;
138
+ /* Tighter than a site's body text, which is often set for long reading. */
139
+ line-height: 1.5;
126
140
  }
127
141
 
128
142
  .atmo__row {
@@ -276,6 +276,12 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
276
276
  box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
277
277
  }
278
278
 
279
+ /* <AtmosphereSubscribe align="end">: near the right-hand edge, the menu opens leftwards. */
280
+ .atmo-subscribe--end .atmo-subscribe__menu {
281
+ right: 0;
282
+ left: auto;
283
+ }
284
+
279
285
  .atmo-subscribe__menu[hidden] {
280
286
  display: none;
281
287
  }
@@ -286,6 +292,23 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
286
292
  border-bottom: 1px solid var(--atmo-border, #8886);
287
293
  color: var(--atmo-muted, inherit);
288
294
  font-size: 0.85em;
295
+ line-height: 1.5;
296
+ }
297
+
298
+ .atmo-subscribe__note p {
299
+ margin: 0;
300
+ }
301
+
302
+ /* "Follow on Bluesky →" under the note, which AtmosphereReactions turns into
303
+ a Follow / Following button for signed-in readers. */
304
+ .atmo-subscribe__follow {
305
+ display: inline-block;
306
+ margin-top: 8px;
307
+ font-weight: 700;
308
+ }
309
+
310
+ .atmo-subscribe__note .atmo-follow {
311
+ margin-top: 8px;
289
312
  }
290
313
 
291
314
  .atmo-subscribe__menu > a[hidden] {
@@ -562,6 +585,7 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
562
585
  }
563
586
 
564
587
  .atmo__preview-post p,
588
+ .atmo__quoted p,
565
589
  .atmo__card span {
566
590
  margin: 0;
567
591
  }
@@ -944,3 +968,72 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
944
968
  radial-gradient(ellipse 34% 26% at 50% 88%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 99%, transparent 100%),
945
969
  var(--atmo-field-bg, #8882);
946
970
  }
971
+
972
+ /* "Liked" / "Reposted" over a button, when something pressed before signing
973
+ in is finished once the reader is back (lib/client/done.ts): the button's
974
+ icon, enlarged, pops in above it with the word, then floats up and fades. */
975
+ .atmo-has-done {
976
+ position: relative;
977
+ }
978
+
979
+ .atmo-done {
980
+ position: absolute;
981
+ z-index: 30;
982
+ bottom: calc(100% + 6px);
983
+ left: 50%;
984
+ display: grid;
985
+ justify-items: center;
986
+ gap: 2px;
987
+ padding: 8px 14px 6px;
988
+ border: 1px solid var(--atmo-border, #8886);
989
+ border-radius: var(--atmo-radius, 10px);
990
+ background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
991
+ box-shadow: 0 10px 28px rgb(0 0 0 / 0.25);
992
+ color: var(--atmo-accent, #1185fe);
993
+ font-size: 0.85rem;
994
+ font-weight: 700;
995
+ line-height: 1.2;
996
+ white-space: nowrap;
997
+ pointer-events: none;
998
+ transform: translateX(-50%);
999
+ animation: atmo-done 1.8s ease-out forwards;
1000
+ }
1001
+
1002
+ /* Specific enough to beat a button's own icon size; its colours still apply. */
1003
+ .atmo-has-done .atmo-done svg.atmo-done__icon {
1004
+ width: 34px;
1005
+ height: 34px;
1006
+ animation: atmo-done-pop 0.5s cubic-bezier(0.3, 1.6, 0.5, 1) both;
1007
+ }
1008
+
1009
+ .atmo-done__label {
1010
+ color: var(--atmo-text, CanvasText);
1011
+ }
1012
+
1013
+ @keyframes atmo-done {
1014
+ 0% { opacity: 0; transform: translate(-50%, 6px); }
1015
+ 12% { opacity: 1; transform: translate(-50%, 0); }
1016
+ 75% { opacity: 1; transform: translate(-50%, 0); }
1017
+ 100% { opacity: 0; transform: translate(-50%, -10px); }
1018
+ }
1019
+
1020
+ @keyframes atmo-done-pop {
1021
+ 0% { transform: scale(0.3); }
1022
+ 60% { transform: scale(1.25); }
1023
+ 100% { transform: scale(1); }
1024
+ }
1025
+
1026
+ @media (prefers-reduced-motion: reduce) {
1027
+ .atmo-done {
1028
+ animation-name: atmo-done-fade;
1029
+ }
1030
+
1031
+ .atmo-has-done .atmo-done svg.atmo-done__icon {
1032
+ animation: none;
1033
+ }
1034
+
1035
+ @keyframes atmo-done-fade {
1036
+ 0%, 100% { opacity: 0; }
1037
+ 12%, 75% { opacity: 1; }
1038
+ }
1039
+ }
@@ -190,14 +190,59 @@ function postAge(when: string): string {
190
190
  return date.toLocaleDateString(lang, { month: "short", day: "numeric", ...(!thisYear && { year: "numeric" }) });
191
191
  }
192
192
 
193
+ /** A post as the site's `post` route gives it (routes/post.ts). */
194
+ export type ShownPost = {
195
+ author: { handle: string; displayName?: string; avatar?: string };
196
+ text: string;
197
+ indexedAt?: string;
198
+ external?: { uri: string; title: string; description: string; thumb?: string };
199
+ };
200
+
201
+ /** Fetches one of the site's posts through the site, or null if it can't. */
202
+ export async function fetchPost(base: string, uri: string): Promise<ShownPost | null> {
203
+ try {
204
+ const response = await fetch(`${base}/post?uri=${encodeURIComponent(uri)}`);
205
+ return response.ok ? ((await response.json()) as ShownPost) : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** Fills a PostPreview (components/PostPreview.astro) inside `container` with `post`. */
212
+ export function showPost(container: HTMLElement, post: ShownPost, ctx: ComposerContext) {
213
+ const $ = <T extends Element>(selector: string) => container.querySelector<T>(selector)!;
214
+ const name = post.author.displayName || post.author.handle;
215
+ $<HTMLElement>("[data-quoted-name]").textContent = name;
216
+ ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), name, post.author.avatar);
217
+ $<HTMLElement>("[data-quoted-handle]").textContent = `@${post.author.handle}`;
218
+ $<HTMLElement>("[data-quoted-age]").textContent = post.indexedAt ? `· ${postAge(post.indexedAt)}` : "";
219
+ $<HTMLElement>("[data-quoted-text]").textContent = post.text;
220
+ $<HTMLElement>("[data-quoted-text]").hidden = !post.text;
221
+ const card = $<HTMLElement>("[data-card]");
222
+ card.hidden = !post.external;
223
+ if (post.external) {
224
+ $<HTMLElement>("[data-card-title]").textContent = post.external.title;
225
+ $<HTMLElement>("[data-card-description]").textContent = post.external.description;
226
+ $<HTMLElement>("[data-card-domain]").textContent = new URL(post.external.uri).hostname;
227
+ const img = $<HTMLImageElement>("[data-card-image]");
228
+ img.hidden = true;
229
+ img.removeAttribute("src");
230
+ if (post.external.thumb) {
231
+ img.onload = () => (img.hidden = false);
232
+ img.onerror = () => (img.hidden = true);
233
+ img.src = post.external.thumb;
234
+ }
235
+ }
236
+ }
237
+
193
238
  /**
194
239
  * The quote preview inside a box: the reader's words as they type, with the
195
240
  * post being quoted beneath, as Bluesky will show it. Call update() as they
196
241
  * type and load(uri) when quoting starts.
197
242
  */
198
243
  export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElement, ctx: ComposerContext) {
199
- const $ = <T extends Element>(selector: string) => form.querySelector<T>(selector)!;
200
- const previewText = $<HTMLElement>("[data-preview-text]");
244
+ const previewText = form.querySelector<HTMLElement>("[data-preview-text]")!;
245
+ const quoted = form.querySelector<HTMLElement>("[data-quoted-post]")!;
201
246
  let loaded = "";
202
247
  const update = () => {
203
248
  const words = textarea.value.trim();
@@ -207,39 +252,11 @@ export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElemen
207
252
  const load = async (uri: string) => {
208
253
  update();
209
254
  if (loaded === uri) return;
210
- try {
211
- const response = await fetch(`${ctx.base}/post?uri=${encodeURIComponent(uri)}`);
212
- if (!response.ok) return;
213
- const quoted = (await response.json()) as {
214
- author: { handle: string; displayName?: string; avatar?: string };
215
- text: string;
216
- indexedAt?: string;
217
- external?: { uri: string; title: string; description: string; thumb?: string };
218
- };
219
- loaded = uri;
220
- const quotedName = quoted.author.displayName || quoted.author.handle;
221
- $<HTMLElement>("[data-quoted-name]").textContent = quotedName;
222
- ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
223
- $<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
224
- $<HTMLElement>("[data-quoted-age]").textContent = quoted.indexedAt ? `· ${postAge(quoted.indexedAt)}` : "";
225
- $<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
226
- const card = $<HTMLElement>("[data-card]");
227
- card.hidden = !quoted.external;
228
- if (quoted.external) {
229
- $<HTMLElement>("[data-card-title]").textContent = quoted.external.title;
230
- $<HTMLElement>("[data-card-description]").textContent = quoted.external.description;
231
- $<HTMLElement>("[data-card-domain]").textContent = new URL(quoted.external.uri).hostname;
232
- const img = $<HTMLImageElement>("[data-card-image]");
233
- img.hidden = true;
234
- if (quoted.external.thumb) {
235
- img.onload = () => (img.hidden = false);
236
- img.onerror = () => (img.hidden = true);
237
- img.src = quoted.external.thumb;
238
- }
239
- }
240
- } catch {
241
- // The preview is a nicety: quoting still works without it.
242
- }
255
+ // The preview is a nicety: quoting still works without it.
256
+ const post = await fetchPost(ctx.base, uri);
257
+ if (!post) return;
258
+ loaded = uri;
259
+ showPost(quoted, post, ctx);
243
260
  };
244
261
  return { update, load };
245
262
  }
@@ -0,0 +1,54 @@
1
+ // A brief "Liked" or "Reposted" over a button, with its icon enlarged and
2
+ // popping, when something the reader pressed before signing in is finished
3
+ // once they're back: otherwise the only sign is a small change of colour,
4
+ // easy to miss after a trip to their account host. Styles: .atmo-done in
5
+ // atmosphere.css (with a plain fade for readers who ask for less motion).
6
+
7
+ /** Shows the bubble over `button` now. */
8
+ export function showDone(button: HTMLElement, label: string) {
9
+ button.querySelector(":scope > .atmo-done")?.remove();
10
+ const bubble = document.createElement("span");
11
+ bubble.className = "atmo-done";
12
+ // Screen readers already hear the component's own status line.
13
+ bubble.setAttribute("aria-hidden", "true");
14
+ const icon = button.querySelector("svg")?.cloneNode(true) as SVGElement | undefined;
15
+ if (icon) {
16
+ icon.removeAttribute("class");
17
+ icon.classList.add("atmo-done__icon");
18
+ bubble.append(icon);
19
+ }
20
+ bubble.append(Object.assign(document.createElement("span"), { className: "atmo-done__label", textContent: label }));
21
+ button.classList.add("atmo-has-done");
22
+ button.append(bubble);
23
+ const finish = () => {
24
+ bubble.remove();
25
+ if (!button.querySelector(":scope > .atmo-done")) button.classList.remove("atmo-has-done");
26
+ };
27
+ bubble.addEventListener("animationend", (event) => {
28
+ if (event.target === bubble) finish();
29
+ });
30
+ // In case animations don't run (a hidden tab, say).
31
+ window.setTimeout(finish, 3000);
32
+ }
33
+
34
+ /**
35
+ * Shows the bubble after `button` is pressed, once its request has finished
36
+ * (the component marks it `data-busy` meanwhile) and only if it's still
37
+ * pressed (aria-pressed="true"), so it appears only when the like or repost
38
+ * actually went through. Call it just before pressing. Gives up after
39
+ * `timeout` milliseconds.
40
+ */
41
+ export function showDoneWhenPressed(button: HTMLElement, label: string, timeout = 20000) {
42
+ let started = false;
43
+ const observer = new MutationObserver(() => {
44
+ if ("busy" in button.dataset) {
45
+ started = true;
46
+ return;
47
+ }
48
+ if (!started) return;
49
+ observer.disconnect();
50
+ if (button.getAttribute("aria-pressed") === "true") showDone(button, label);
51
+ });
52
+ observer.observe(button, { attributes: true, attributeFilter: ["data-busy", "aria-pressed"] });
53
+ window.setTimeout(() => observer.disconnect(), timeout);
54
+ }
package/lib/resolve.ts CHANGED
@@ -12,7 +12,8 @@ async function getJson<T>(url: string): Promise<T> {
12
12
  return (await response.json()) as T;
13
13
  }
14
14
 
15
- async function resolveHandle(handle: string): Promise<string> {
15
+ /** A handle → its DID, through Bluesky's public service. */
16
+ export async function resolveHandle(handle: string): Promise<string> {
16
17
  const { did } = await getJson<{ did: string }>(
17
18
  `${APPVIEW}com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
18
19
  );
package/locales/de.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "signIn.heading": "Melde dich an, um zu kommentieren, zu liken oder zu reposten",
3
- "signIn.anyAccount": "Jedes {{atmosphereAccount}} funktioniert, auch {{bluesky}}: ein Konto für ein ganzes Netzwerk offener Apps.",
3
+ "signIn.anyAccount": "Nutze dein {{bluesky}}-Konto oder ein anderes {{atmosphereAccount}}.",
4
+ "signIn.noAccount": "Noch kein Konto? {{createAccount}}.",
4
5
  "signIn.atmosphereAccount": "Atmosphere-Konto",
5
6
  "signIn.button": "Mit Atmosphere anmelden",
6
7
  "signIn.matchingAccounts": "Passende Konten",
7
- "signIn.privacy": "Du bestätigst das auf der Seite deines eigenen Kontos (für die meisten bei Bluesky); dein Passwort wird hier nie weitergegeben. Noch kein Konto? {{createAccount}}.",
8
+ "signIn.privacy": "Du meldest dich auf der Seite deines eigenen Kontos an (für die meisten die von Bluesky). Deine Anmeldung bleibt in diesem Browser. {{site}} kann sie nur für das nutzen, was du erlaubt hast, und wenn du dich hier abmeldest, endet sie.",
9
+ "signIn.thisSite": "Diese Website",
8
10
  "signIn.createAccount": "Erstelle ein kostenloses Atmosphere-Konto",
9
- "elsewhere.text": "Du möchtest dich hier lieber nicht anmelden? {{link}} Deine Antwort erscheint hier genauso, ebenso Likes und Reposts.",
10
- "elsewhere.link": "Antworte stattdessen auf Bluesky →",
11
+ "elsewhere.text": "Lieber nicht hier anmelden? {{link}}",
12
+ "elsewhere.link": "Auf Bluesky reagieren →",
11
13
  "actions.label": "Auf diesen Beitrag reagieren",
12
14
  "actions.like": "Gefällt mir",
13
15
  "actions.liked": "Gefällt dir",
@@ -90,9 +92,9 @@
90
92
  "follow.following": "Folge ich",
91
93
  "follow.followAs": "{{handle}} als {{me}} folgen",
92
94
  "follow.followingLabel": "Du folgst {{handle}}",
95
+ "follow.link": "{{handle}} auf Bluesky folgen",
93
96
  "credit": "Bereitgestellt von {{link}}",
94
97
  "callback.signingIn": "Anmeldung läuft …",
95
- "signIn.access": "Deine Anmeldung bleibt in diesem Browser: Der Server dieser Seite sieht sie nie und kann nicht für dich posten, und beim Abmelden wird sie aufgehoben.",
96
98
  "signIn.policy": "Wie diese Seite mit deinen Daten umgeht",
97
99
  "composer.needsPermission": "Um hier als {{handle}} zu kommentieren, braucht diese Seite eine weitere Erlaubnis von deinem Atmosphere-Konto.",
98
100
  "comment.hide": "Ausblenden",
@@ -111,5 +113,10 @@
111
113
  "comment.withheldTitle": "Kommentar ausgeblendet",
112
114
  "comment.withheldSignedIn": "Er konnte gerade nicht geladen werden. Lade die Seite neu.",
113
115
  "signInFirst.view": "Melde dich an, um ausgeblendete Kommentare zu sehen",
114
- "reactions.hiddenPerson": "Jemand, der sein Konto nur angemeldeten Leuten zeigt"
116
+ "reactions.hiddenPerson": "Jemand, der sein Konto nur angemeldeten Leuten zeigt",
117
+ "subscribe.panelText": "Abonniere, um neue Blogbeiträge in Apps wie {{leaflet}} oder {{standardReader}} zu lesen.",
118
+ "subscribe.panelTextFeed": "Abonniere, um neue Blogbeiträge in Apps wie {{leaflet}} oder {{standardReader}} zu lesen, oder folge dem Feed in einem beliebigen Feedreader.",
119
+ "subscribe.followNote": "Um alles zu sehen, was {{handle}} postet, folge dem Konto auf Bluesky.",
120
+ "subscribe.follow": "Auf Bluesky folgen",
121
+ "subscribe.leaflet": "Auf Leaflet ansehen"
115
122
  }
package/locales/en.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "signIn.heading": "Sign in to leave a comment",
3
- "signIn.anyAccount": "Use {{bluesky}} or any other {{atmosphereAccount}}. {{createAccount}}",
3
+ "signIn.anyAccount": "Use your {{bluesky}} account, or any other {{atmosphereAccount}}.",
4
+ "signIn.noAccount": "No account? {{createAccount}}.",
4
5
  "signIn.atmosphereAccount": "Atmosphere account",
5
6
  "signIn.more": "How does signing in work?",
6
7
  "signIn.button": "Sign in with Atmosphere",
7
8
  "signIn.matchingAccounts": "Matching accounts",
8
- "signIn.privacy": "You sign in on your account's own site (Bluesky's, for most people) and approve what this site can do. Your password is never shared here.",
9
+ "signIn.privacy": "You sign in on your account's own site (Bluesky's, for most people). Your sign-in is kept in this browser. {{site}} can use it only for what you approved, and signing out here ends it.",
10
+ "signIn.thisSite": "This site",
9
11
  "signIn.createAccount": "Create one for free",
10
- "elsewhere.text": "Rather not sign in? {{link}}",
11
- "elsewhere.link": "Reply on Bluesky instead →",
12
+ "elsewhere.text": "Rather not sign in here? {{link}}",
13
+ "elsewhere.link": "Respond on Bluesky →",
12
14
  "actions.label": "Respond to this post",
13
15
  "actions.likeAndRecommend": "Like on Bluesky, and recommend in apps such as Leaflet",
14
16
  "actions.like": "Like",
@@ -97,6 +99,7 @@
97
99
  "follow.following": "Following",
98
100
  "follow.followAs": "Follow {{handle}} as {{me}}",
99
101
  "follow.followingLabel": "You follow {{handle}}",
102
+ "follow.link": "Follow {{handle}} on Bluesky",
100
103
  "subscribe.subscribe": "Subscribe",
101
104
  "subscribe.subscribed": "✓ Subscribed",
102
105
  "subscribe.subscribeLabel": "Subscribe with your Atmosphere account, to read new posts in apps such as Leaflet or Standard Reader",
@@ -108,10 +111,13 @@
108
111
  "subscribe.feed": "RSS feed",
109
112
  "subscribe.viewBluesky": "View on Bluesky",
110
113
  "subscribe.standardReader": "View on Standard Reader",
111
- "subscribe.panelText": "Read new posts in apps such as {{leaflet}} or {{standardReader}}.",
114
+ "subscribe.leaflet": "View on Leaflet",
115
+ "subscribe.panelText": "Subscribe to read new blog posts in apps such as {{leaflet}} or {{standardReader}}.",
116
+ "subscribe.panelTextFeed": "Subscribe to read new blog posts in apps such as {{leaflet}} or {{standardReader}}, or follow the feed in any feed reader.",
117
+ "subscribe.followNote": "To see everything {{handle}} posts, follow it on Bluesky.",
118
+ "subscribe.follow": "Follow on Bluesky",
112
119
  "credit": "Powered by {{link}}",
113
120
  "callback.signingIn": "Signing you in…",
114
- "signIn.access": "Your sign-in is kept only in this browser. This site's server never sees it, so it can't post for you. Signing out here cancels it.",
115
121
  "signIn.policy": "How this site handles your data",
116
122
  "composer.needsPermission": "To comment here as {{handle}}, this site needs one more permission from your Atmosphere account.",
117
123
  "comment.hide": "Hide",