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.
@@ -0,0 +1,785 @@
1
+ ---
2
+ // Like and Repost (with their counts), and Reply and Quote links to Bluesky,
3
+ // under any Bluesky post your page shows: a feed of your latest posts, say.
4
+ // Put it once in your layout, and mark each post's element with its AT URI:
5
+ //
6
+ // <article data-atmosphere-post="at://did:plc:…/app.bsky.feed.post/…"
7
+ // data-atmosphere-url="https://bsky.app/profile/…/post/…"
8
+ // data-atmosphere-likes="3" data-atmosphere-reposts="1">
9
+ //
10
+ // It also fills any element marked `data-atmosphere-account` (in your
11
+ // header, say) with the signed-in reader's avatar, which opens a small menu
12
+ // saying who they're signed in as, with Sign out. Signed out, the element
13
+ // stays empty (give it `hidden` so it takes no space).
14
+ //
15
+ // A link marked `data-atmosphere-follow="<DID>"` (and
16
+ // `data-atmosphere-follow-handle="you.bsky.social"`), such as "Follow me on
17
+ // Bluesky", becomes a Follow / Following button for signed-in readers; a
18
+ // second press unfollows. The first follow asks for one more permission. Only
19
+ // `allowedAuthors` can be followed through your site.
20
+ //
21
+ // Signed-in readers like and repost from your page. Anyone else who presses
22
+ // Like or Repost is offered the sign-in form in an overlay, and the like or
23
+ // repost is done once they're back. Only posts by the integration's
24
+ // `allowedAuthors` can be liked or reposted through your site.
25
+ //
26
+ // Props: locale, messages (as for AtmosphereComments).
27
+ import settings from "../lib/settings";
28
+ import { messagesFor, pickLocale, translate } from "../lib/i18n";
29
+ import SignInForm from "./SignInForm.astro";
30
+ import Composer from "./Composer.astro";
31
+ import "./atmosphere.css";
32
+
33
+ interface Props {
34
+ /** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
35
+ locale?: string;
36
+ /** Your own wording for any line, by its key in locales/en.json. */
37
+ messages?: Record<string, string>;
38
+ }
39
+
40
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
41
+ const messages = messagesFor(lang, { ...settings.messages, ...Astro.props.messages });
42
+ const t = (key: string) => translate(messages, lang, key);
43
+ const { base } = settings;
44
+ ---
45
+
46
+ <atmosphere-reactions data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
47
+ <dialog class="atmo atmo-signin" aria-label={t("dialog.heading")} data-signin-dialog>
48
+ <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
49
+ <SignInForm id="atmo-dialog" returnTo={Astro.url.pathname} messages={messages} lang={lang} heading={t("dialog.heading")} />
50
+ <p class="atmo-signin__status" role="status" data-dialog-status></p>
51
+ </dialog>
52
+ {/* Reply or Quote, for signed-in readers: the comment box in an overlay. */}
53
+ <dialog class="atmo atmo-signin atmo-compose" aria-label={t("postActions.replyLabel")} data-compose-dialog>
54
+ <button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
55
+ <Composer id="atmo-compose" messages={messages} lang={lang} cancel />
56
+ <div class="atmo-compose__done" data-compose-done hidden>
57
+ <p data-compose-done-text></p>
58
+ <button type="button" class="atmo__link-button" data-close>{t("dialog.close")}</button>
59
+ </div>
60
+ <p class="atmo-signin__status" role="status" data-compose-status></p>
61
+ </dialog>
62
+ </atmosphere-reactions>
63
+
64
+ <script>
65
+ import { translate } from "../lib/translate";
66
+ import { enhanceHandleBox } from "../lib/client/handle-suggestions";
67
+ import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
68
+
69
+ const HEART = '<path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" />';
70
+ const ARROWS = '<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" />';
71
+ const BUBBLE = '<path d="M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4 3.5V16h0.5A2.5 2.5 0 0 1 5 13.5Z" />';
72
+ // The Bluesky butterfly (Simple Icons, CC0), on the Follow button.
73
+ const BUTTERFLY = '<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" />';
74
+ const QUOTE = '<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" />';
75
+ // Messages the sign-in round trip can bring back (?atmosphere=…).
76
+ const SIGN_IN_PROBLEMS: Record<string, string> = {
77
+ "sign-in-failed": "messages.signInFailed",
78
+ "account-not-found": "messages.accountNotFound",
79
+ "invalid-handle": "messages.invalidHandle",
80
+ };
81
+
82
+ type Kind = "like" | "repost";
83
+ // Error codes from the routes, and their wording (as in the comments).
84
+ const MESSAGE_KEYS: Record<string, string> = {
85
+ "signed-out": "messages.signedOut",
86
+ "new-permissions": "messages.newPermissions",
87
+ "not-allowed": "messages.notAllowed",
88
+ "too-long": "messages.tooLong",
89
+ "too-many": "messages.tooMany",
90
+ error: "messages.error",
91
+ };
92
+
93
+ class AtmosphereReactions extends HTMLElement {
94
+ connectedCallback() {
95
+ const base = this.dataset.base ?? "/atmosphere";
96
+ const lang = this.dataset.locale || "en";
97
+ const wording = JSON.parse(this.dataset.messages || "{}") as Record<string, string>;
98
+ const t = (key: string, vars?: Record<string, string | number>) => translate(wording, lang, key, vars);
99
+ const dialog = this.querySelector<HTMLDialogElement>("[data-signin-dialog]")!;
100
+ const form = dialog.querySelector<HTMLFormElement>("[data-login-form]")!;
101
+ const status = dialog.querySelector<HTMLElement>("[data-dialog-status]")!;
102
+ let signedIn = false;
103
+ let handle = "";
104
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
105
+ // What the reader has liked or reposted, by post.
106
+ const known: Record<string, { liked: boolean; reposted: boolean }> = {};
107
+
108
+ enhanceHandleBox(form, base);
109
+ for (const overlay of this.querySelectorAll<HTMLDialogElement>("dialog")) {
110
+ overlay.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", () => overlay.close()));
111
+ // A click on the dimmed page around the overlay closes it.
112
+ overlay.addEventListener("click", (event) => {
113
+ if (event.target === overlay) overlay.close();
114
+ });
115
+ }
116
+
117
+ // An avatar picture, or the name's first letter if there isn't one.
118
+ const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
119
+ slot.textContent = name.trim().charAt(0).toUpperCase();
120
+ if (!src) return;
121
+ const img = Object.assign(document.createElement("img"), { src, alt: "" });
122
+ img.onload = () => slot.replaceChildren(img);
123
+ };
124
+ const message = (code: string) => t(MESSAGE_KEYS[code] ?? MESSAGE_KEYS.error, { max: MAX });
125
+
126
+ // Reply and Quote for signed-in readers: the comment box in an overlay,
127
+ // posting to their own account. What they post shows on Bluesky, not
128
+ // on this page, so the overlay ends by saying so, with a link to it.
129
+ const compose = this.querySelector<HTMLDialogElement>("[data-compose-dialog]")!;
130
+ const composeForm = compose.querySelector<HTMLFormElement>("[data-composer]")!;
131
+ const composeStatus = compose.querySelector<HTMLElement>("[data-compose-status]")!;
132
+ const composeDone = compose.querySelector<HTMLElement>("[data-compose-done]")!;
133
+ const $c = <T extends Element>(selector: string) => composeForm.querySelector<T>(selector)!;
134
+ const composerContext = { base, t, message, setAvatar };
135
+ let composeRoot = "";
136
+ let composeMode: "reply" | "quote" = "reply";
137
+ const composer = setUpComposer(composeForm, composerContext, {
138
+ root: () => composeRoot,
139
+ extra: () => (composeMode === "quote" ? { quote: true } : {}),
140
+ tell: (text) => (composeStatus.textContent = text),
141
+ onPosted: (data: Posted) => {
142
+ composeStatus.textContent = "";
143
+ composeForm.hidden = true;
144
+ const done = compose.querySelector<HTMLElement>("[data-compose-done-text]")!;
145
+ done.replaceChildren(
146
+ `${t(data.quote ? "posted.quote" : "posted.reply")} ${t("posted.notHere")} `,
147
+ Object.assign(make("a", "", t("posted.see")), { href: data.url, target: "_blank", rel: "noopener noreferrer" }),
148
+ );
149
+ composeDone.hidden = false;
150
+ this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
151
+ },
152
+ onError: (code, tell) => {
153
+ if (code === "signed-out" || code === "new-permissions") signedIn = false;
154
+ tell(message(code));
155
+ },
156
+ onInput: () => quote.update(),
157
+ });
158
+ const quote = quotePreview(composeForm, composer.textarea, composerContext);
159
+ $c<HTMLElement>("[data-cancel-context]").hidden = true;
160
+
161
+ const openCompose = (post: HTMLElement, mode: "reply" | "quote") => {
162
+ composeRoot = post.dataset.atmospherePost!;
163
+ composeMode = mode;
164
+ const author = post.dataset.atmosphereUrl?.match(/\/profile\/([^/]+)\//)?.[1];
165
+ $c<HTMLElement>("[data-context]").hidden = false;
166
+ $c<HTMLElement>("[data-context-text]").textContent =
167
+ mode === "quote" ? t("composer.quoting") : t("composer.replyingTo", { name: author ? `@${author}` : t("comment.someone") });
168
+ $c<HTMLElement>("[data-verb]").textContent = t(mode === "quote" ? "composer.quote" : "comment.reply");
169
+ $c<HTMLElement>("[data-submit]").textContent = t(mode === "quote" ? "composer.postQuote" : "composer.postReply");
170
+ $c<HTMLElement>("[data-preview]").hidden = mode !== "quote";
171
+ compose.setAttribute("aria-label", t(mode === "quote" ? "postActions.quoteLabel" : "postActions.replyLabel"));
172
+ composer.textarea.value = "";
173
+ composer.updateCount();
174
+ composeStatus.textContent = "";
175
+ composeDone.hidden = true;
176
+ composeForm.hidden = false;
177
+ if (mode === "quote") void quote.load(composeRoot);
178
+ if (!compose.open) compose.showModal();
179
+ composer.textarea.focus();
180
+ };
181
+
182
+ // The reader, in the box: "Reply as @handle" with their avatar, and in
183
+ // the quote preview.
184
+ const showMe = () => {
185
+ const name = me.displayName || me.handle || "";
186
+ $c<HTMLElement>("[data-handle]").textContent = handle;
187
+ setAvatar($c<HTMLElement>("[data-me-avatar]"), name, me.avatar);
188
+ $c<HTMLElement>("[data-preview-name]").textContent = name;
189
+ $c<HTMLElement>("[data-preview-handle]").textContent = handle;
190
+ setAvatar($c<HTMLElement>("[data-preview-avatar]"), name, me.avatar);
191
+ };
192
+
193
+ const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className = "", text?: string) => {
194
+ const element = document.createElement(tag);
195
+ if (className) element.className = className;
196
+ if (text !== undefined) element.textContent = text;
197
+ return element;
198
+ };
199
+
200
+ // Not signed in: the sign-in form, coming back to finish this like or repost.
201
+ const askToSignIn = (kind: Kind, uri: string, problem = "") => {
202
+ form.querySelector<HTMLInputElement>("[data-return-to]")!.value =
203
+ `${location.pathname}${location.search}#atmo-react=${kind}:${encodeURIComponent(uri)}`;
204
+ status.textContent = problem;
205
+ if (!dialog.open) dialog.showModal();
206
+ form.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
207
+ };
208
+
209
+ const send = (body: unknown) =>
210
+ fetch(`${base}/react`, {
211
+ method: "POST",
212
+ credentials: "same-origin",
213
+ headers: { "content-type": "application/json" },
214
+ body: JSON.stringify(body),
215
+ });
216
+
217
+ const icon = (paths: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
218
+
219
+ const setCount = (button: HTMLElement, value: number) => {
220
+ button.dataset.count = String(value);
221
+ button.querySelector("span")!.textContent = value ? String(value) : "";
222
+ };
223
+
224
+ const react = async (post: HTMLElement, button: HTMLButtonElement, kind: Kind, on: boolean) => {
225
+ const uri = post.dataset.atmospherePost!;
226
+ if (!signedIn) return askToSignIn(kind, uri);
227
+ const before = Number(button.dataset.count || 0);
228
+ button.setAttribute("aria-pressed", String(on));
229
+ setCount(button, Math.max(0, before + (on ? 1 : -1)));
230
+ try {
231
+ const response = await send({ post: uri, kind, on });
232
+ if (response.ok) {
233
+ known[uri] = { ...(known[uri] ?? { liked: false, reposted: false }), [kind === "like" ? "liked" : "reposted"]: on };
234
+ return;
235
+ }
236
+ const data = (await response.json().catch(() => ({}))) as { error?: string };
237
+ button.setAttribute("aria-pressed", String(!on));
238
+ setCount(button, before);
239
+ if (data.error === "signed-out" || data.error === "new-permissions") {
240
+ signedIn = false;
241
+ labelButton(button, kind);
242
+ askToSignIn(kind, uri, t(data.error === "signed-out" ? "messages.signedOut" : "messages.newPermissions"));
243
+ }
244
+ } catch {
245
+ button.setAttribute("aria-pressed", String(!on));
246
+ setCount(button, before);
247
+ }
248
+ };
249
+
250
+ // "Like as @handle" when signed in (so readers know which account),
251
+ // "Like this post" otherwise; the same for screen readers.
252
+ const labelButton = (button: HTMLElement, kind: Kind) => {
253
+ button.title = signedIn && handle
254
+ ? t(kind === "like" ? "postActions.likeAs" : "postActions.repostAs", { handle })
255
+ : t(kind === "like" ? "postActions.likeLabel" : "postActions.repostLabel");
256
+ button.setAttribute("aria-label", button.title);
257
+ };
258
+
259
+ const labelLink = (link: HTMLElement) => {
260
+ const quote = link.dataset.kind === "quote";
261
+ link.title = signedIn && handle
262
+ ? t(quote ? "postActions.quoteAs" : "postActions.replyAs", { handle })
263
+ : t(quote ? "postActions.quoteLabel" : "postActions.replyLabel");
264
+ link.setAttribute("aria-label", link.title);
265
+ };
266
+
267
+ const reactButton = (post: HTMLElement, kind: Kind) => {
268
+ const button = make("button", "atmo-post-action");
269
+ button.type = "button";
270
+ button.dataset.kind = kind;
271
+ labelButton(button, kind);
272
+ const seen = known[post.dataset.atmospherePost!];
273
+ button.setAttribute("aria-pressed", String(!!(kind === "like" ? seen?.liked : seen?.reposted)));
274
+ button.innerHTML = `${icon(kind === "like" ? HEART : ARROWS)}<span></span>`;
275
+ setCount(button, Number(post.dataset[kind === "like" ? "atmosphereLikes" : "atmosphereReposts"] || 0));
276
+ button.addEventListener("click", () => react(post, button, kind, button.getAttribute("aria-pressed") !== "true"));
277
+ return button;
278
+ };
279
+
280
+ // Reply and Quote: the comment box in an overlay for signed-in readers,
281
+ // otherwise the post on Bluesky (as also with ⌘-click or a middle click).
282
+ // Icons only, as on Bluesky, with their names for screen readers and as
283
+ // tooltips.
284
+ const bskyLink = (post: HTMLElement, paths: string) => {
285
+ const link = make("a", "atmo-post-action");
286
+ const mode = paths === BUBBLE ? "reply" : "quote";
287
+ link.href = post.dataset.atmosphereUrl ?? "#";
288
+ link.target = "_blank";
289
+ link.rel = "noopener noreferrer";
290
+ link.dataset.kind = mode;
291
+ labelLink(link);
292
+ link.innerHTML = icon(paths);
293
+ link.addEventListener("click", (event) => {
294
+ if (!signedIn || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
295
+ event.preventDefault();
296
+ openCompose(post, mode);
297
+ });
298
+ return link;
299
+ };
300
+
301
+ const posts = () => [...document.querySelectorAll<HTMLElement>("[data-atmosphere-post]")];
302
+
303
+ // Returns how many posts got buttons, so new arrivals can be checked.
304
+ const addActions = (): number => {
305
+ let added = 0;
306
+ for (const post of posts()) {
307
+ if (post.querySelector(":scope > .atmo-post-actions")) continue;
308
+ added += 1;
309
+ const actions = make("div", "atmo-post-actions");
310
+ actions.append(
311
+ bskyLink(post, BUBBLE),
312
+ reactButton(post, "repost"),
313
+ bskyLink(post, QUOTE),
314
+ reactButton(post, "like"),
315
+ );
316
+ post.append(actions);
317
+ }
318
+ return added;
319
+ };
320
+
321
+ // Which of the posts the reader has already liked or reposted.
322
+ const loadState = async () => {
323
+ const uris = posts().map((post) => post.dataset.atmospherePost!);
324
+ if (!signedIn || !uris.length) return;
325
+ try {
326
+ const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
327
+ const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
328
+ if (!response.ok) return;
329
+ const state = ((await response.json()) as { posts?: typeof known }).posts ?? {};
330
+ Object.assign(known, state);
331
+ for (const post of posts()) {
332
+ const seen = state[post.dataset.atmospherePost!];
333
+ if (!seen) continue;
334
+ post.querySelector(".atmo-post-action[data-kind='like']")?.setAttribute("aria-pressed", String(seen.liked));
335
+ post.querySelector(".atmo-post-action[data-kind='repost']")?.setAttribute("aria-pressed", String(seen.reposted));
336
+ }
337
+ } catch {
338
+ // The buttons stay as they are; a click still works.
339
+ }
340
+ };
341
+
342
+ // Follow buttons in place of links marked data-atmosphere-follow, for
343
+ // signed-in readers: "Follow @handle", then "Following" (a second press
344
+ // unfollows, as on Bluesky, without inviting it).
345
+ const followButtons = new Map<string, { following: boolean; set: (on: boolean) => void; press: () => Promise<void> }>();
346
+ // Following isn't part of signing in: the first follow asks the
347
+ // reader's account host for it, then carries on when they're back.
348
+ const askFollowPermission = (subject: string) => {
349
+ const permit = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
350
+ permit.hidden = true;
351
+ const field = (name: string, value: string) =>
352
+ permit.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
353
+ field("handle", me.did || me.handle || "");
354
+ field("permission", "follow");
355
+ field("returnTo", `${location.pathname}${location.search}#atmo-follow=${encodeURIComponent(subject)}`);
356
+ document.body.append(permit);
357
+ permit.submit();
358
+ };
359
+ const setUpFollow = async () => {
360
+ for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
361
+ const subject = link.dataset.atmosphereFollow!;
362
+ if (followButtons.has(subject) || link.nextElementSibling?.matches(".atmo-follow")) continue;
363
+ // Nobody can follow themselves: the account's owner keeps the link.
364
+ if (subject === me.did) continue;
365
+ const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
366
+ const button = make("button", "atmo-follow");
367
+ button.type = "button";
368
+ let following = false;
369
+ // The account's avatar, through the site (its own accounts' are always served).
370
+ const avatar = make("img", "atmo-follow__avatar");
371
+ avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
372
+ avatar.alt = "";
373
+ avatar.onerror = () => avatar.remove();
374
+ const render = () => {
375
+ button.setAttribute("aria-pressed", String(following));
376
+ // The butterfly, then "Follow" with the account's avatar beside its
377
+ // handle (wherever the language puts the handle), or the avatar
378
+ // with "Following".
379
+ button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${BUTTERFLY}</svg>`;
380
+ if (following) {
381
+ button.append(avatar, make("span", "", `✓ ${t("follow.following")}`));
382
+ } else {
383
+ const [before, after = ""] = t("follow.follow").split("{{handle}}");
384
+ if (before.trim()) button.append(make("span", "", before.trim()));
385
+ button.append(avatar, make("span", "", who));
386
+ if (after.trim()) button.append(make("span", "", after.trim()));
387
+ }
388
+ button.title = following ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
389
+ button.setAttribute("aria-label", button.title);
390
+ };
391
+ const set = (on: boolean) => {
392
+ following = on;
393
+ render();
394
+ };
395
+ const press = async () => {
396
+ button.disabled = true;
397
+ try {
398
+ const response = await fetch(`${base}/follow`, {
399
+ method: "POST",
400
+ credentials: "same-origin",
401
+ headers: { "content-type": "application/json" },
402
+ body: JSON.stringify({ subject, on: !following }),
403
+ });
404
+ const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
405
+ if (response.ok) return set(!!data.following);
406
+ if (data.error === "needs-permission") return askFollowPermission(subject);
407
+ if (data.error === "signed-out" || data.error === "new-permissions") location.reload();
408
+ } finally {
409
+ button.disabled = false;
410
+ }
411
+ };
412
+ button.addEventListener("click", () => void press());
413
+ followButtons.set(subject, { get following() { return following; }, set, press });
414
+ render();
415
+ // The button takes the link's place. (Inline, as a site's own style
416
+ // for the link could otherwise keep it showing.)
417
+ link.hidden = true;
418
+ link.style.display = "none";
419
+ link.after(button);
420
+ try {
421
+ const response = await fetch(`${base}/follow?subject=${encodeURIComponent(subject)}`, { credentials: "same-origin" });
422
+ if (response.ok) set(!!((await response.json()) as { following?: boolean }).following);
423
+ } catch {
424
+ // Shown as Follow; pressing it still works.
425
+ }
426
+ }
427
+ };
428
+
429
+ // Back from approving following: follow as they asked.
430
+ const finishPendingFollow = async () => {
431
+ const pending = location.hash.match(/^#atmo-follow=(.+)$/);
432
+ if (!pending) return;
433
+ const subject = decodeURIComponent(pending[1]);
434
+ const url = new URL(location.href);
435
+ if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
436
+ history.replaceState(history.state, "", `${url.pathname}${url.search}`);
437
+ const follow = followButtons.get(subject);
438
+ if (signedIn && follow && !follow.following) await follow.press();
439
+ };
440
+
441
+ // Back from signing in: finish the like or repost they pressed.
442
+ const finishPending = async () => {
443
+ const pending = location.hash.match(/^#atmo-react=(like|repost):(.+)$/);
444
+ if (!pending) return;
445
+ const [, kind, encoded] = pending;
446
+ const uri = decodeURIComponent(encoded);
447
+ const url = new URL(location.href);
448
+ const problem = url.searchParams.get("atmosphere") ?? "";
449
+ // The comments component tidies this away on post pages; elsewhere, do it here.
450
+ if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
451
+ // The page may still be loading its posts (a server island, say).
452
+ for (let tries = 0; tries < 40 && !posts().some((post) => post.dataset.atmospherePost === uri); tries++) {
453
+ await new Promise((resolve) => setTimeout(resolve, 250));
454
+ }
455
+ const post = posts().find((candidate) => candidate.dataset.atmospherePost === uri);
456
+ history.replaceState(history.state, "", `${url.pathname}${url.search}`);
457
+ if (!post) return;
458
+ post.scrollIntoView({ block: "center" });
459
+ if (!signedIn) {
460
+ if (SIGN_IN_PROBLEMS[problem]) askToSignIn(kind as Kind, uri, t(SIGN_IN_PROBLEMS[problem]));
461
+ return;
462
+ }
463
+ const button = post.querySelector<HTMLButtonElement>(`.atmo-post-action[data-kind='${kind}']`);
464
+ if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
465
+ };
466
+
467
+ // The reader's avatar wherever the page marks data-atmosphere-account,
468
+ // opening a menu with who they're signed in as and Sign out.
469
+ const showAccount = (me: { handle?: string; displayName?: string; avatar?: string }) => {
470
+ for (const slot of document.querySelectorAll<HTMLElement>("[data-atmosphere-account]")) {
471
+ if (slot.querySelector(".atmo-account")) continue;
472
+ const handle = `@${me.handle}`;
473
+ const account = make("span", "atmo-account");
474
+ const button = make("button", "atmo-account__button");
475
+ button.type = "button";
476
+ button.title = t("account.signedInAs", { handle });
477
+ button.setAttribute("aria-label", button.title);
478
+ button.setAttribute("aria-haspopup", "true");
479
+ button.setAttribute("aria-expanded", "false");
480
+ const name = me.displayName || me.handle || "";
481
+ button.textContent = name.trim().charAt(0).toUpperCase();
482
+ if (me.avatar) {
483
+ const img = Object.assign(document.createElement("img"), { src: me.avatar, alt: "" });
484
+ img.onload = () => button.replaceChildren(img);
485
+ }
486
+ const menu = make("div", "atmo-account__menu");
487
+ menu.hidden = true;
488
+ const who = make("p", "atmo-account__who", t("account.signedInAsLabel"));
489
+ const strong = make("strong", "", handle);
490
+ who.append(make("br"), strong);
491
+ const signOut = make("button", "atmo-account__sign-out", t("account.signOut"));
492
+ signOut.type = "button";
493
+ signOut.addEventListener("click", async () => {
494
+ signOut.disabled = true;
495
+ await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
496
+ location.reload();
497
+ });
498
+ menu.append(who, signOut);
499
+ const close = () => {
500
+ menu.hidden = true;
501
+ button.setAttribute("aria-expanded", "false");
502
+ };
503
+ button.addEventListener("click", () => {
504
+ const open = menu.hidden;
505
+ menu.hidden = !open;
506
+ button.setAttribute("aria-expanded", String(open));
507
+ if (open) signOut.focus();
508
+ });
509
+ document.addEventListener("click", (event) => {
510
+ if (!account.contains(event.target as Node)) close();
511
+ });
512
+ account.addEventListener("keydown", (event) => {
513
+ if (event.key === "Escape" && !menu.hidden) {
514
+ close();
515
+ button.focus();
516
+ }
517
+ });
518
+ account.append(button, menu);
519
+ slot.append(account);
520
+ slot.hidden = false;
521
+ }
522
+ };
523
+
524
+ fetch(`${base}/me`, { credentials: "same-origin" })
525
+ .then((response) => response.json())
526
+ .then((found: { signedIn?: boolean; handle?: string; displayName?: string; avatar?: string }) => {
527
+ me = found;
528
+ signedIn = !!found.signedIn;
529
+ handle = found.handle ? `@${found.handle}` : "";
530
+ if (signedIn) {
531
+ showAccount(found);
532
+ showMe();
533
+ }
534
+ // Buttons added before we knew get the signed-in wording too.
535
+ for (const button of document.querySelectorAll<HTMLElement>(".atmo-post-action[data-kind='like'], .atmo-post-action[data-kind='repost']")) {
536
+ labelButton(button, button.dataset.kind as Kind);
537
+ }
538
+ document.querySelectorAll<HTMLElement>(".atmo-post-action[data-kind='reply'], .atmo-post-action[data-kind='quote']").forEach(labelLink);
539
+ })
540
+ .catch(() => undefined)
541
+ .finally(async () => {
542
+ addActions();
543
+ await loadState();
544
+ await finishPending();
545
+ if (signedIn) {
546
+ await setUpFollow();
547
+ await finishPendingFollow();
548
+ }
549
+ });
550
+
551
+ // Posts that arrive later (a server island, say) get buttons too.
552
+ let recheck: number | undefined;
553
+ new MutationObserver(() => {
554
+ if (!addActions() || !signedIn) return;
555
+ window.clearTimeout(recheck);
556
+ recheck = window.setTimeout(loadState, 300);
557
+ }).observe(document.body, { childList: true, subtree: true });
558
+ }
559
+ }
560
+
561
+ if (!customElements.get("atmosphere-reactions")) customElements.define("atmosphere-reactions", AtmosphereReactions);
562
+ </script>
563
+
564
+ <style>
565
+ .atmo-signin {
566
+ /* Centred on the screen, whatever margin a site gives .atmo. */
567
+ margin: auto;
568
+ width: min(92vw, 520px);
569
+ padding: 22px 22px 18px;
570
+ border: 1px solid var(--atmo-border, #8886);
571
+ border-radius: calc(var(--atmo-radius, 10px) + 6px);
572
+ background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
573
+ color: var(--atmo-text, CanvasText);
574
+ box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
575
+ }
576
+
577
+ .atmo-signin::backdrop {
578
+ background: rgb(0 0 0 / 0.55);
579
+ }
580
+
581
+ .atmo-signin :global(.atmo__login) {
582
+ margin-top: 0;
583
+ }
584
+
585
+ .atmo-signin__close {
586
+ position: absolute;
587
+ top: 8px;
588
+ right: 10px;
589
+ padding: 4px 8px;
590
+ border: 0;
591
+ background: none;
592
+ color: var(--atmo-muted, inherit);
593
+ font: inherit;
594
+ font-size: 1.4em;
595
+ line-height: 1;
596
+ cursor: pointer;
597
+ }
598
+
599
+ .atmo-compose {
600
+ width: min(92vw, 600px);
601
+ }
602
+
603
+ .atmo-compose :global(.atmo__composer) {
604
+ margin-top: 0;
605
+ }
606
+
607
+ .atmo-compose__done p {
608
+ margin: 0 0 12px;
609
+ }
610
+
611
+ .atmo-signin__status {
612
+ margin: 10px 0 0;
613
+ color: var(--atmo-muted, inherit);
614
+ font-size: 0.9em;
615
+ }
616
+
617
+ .atmo-signin__status:empty {
618
+ display: none;
619
+ }
620
+
621
+ /* The row under each post. Defaults carry no weight, so a site's own
622
+ rules always win. */
623
+ :global(:where(.atmo-post-actions)) {
624
+ display: flex;
625
+ flex-wrap: wrap;
626
+ gap: 18px;
627
+ margin-top: 10px;
628
+ }
629
+
630
+ :global(:where(.atmo-post-action)) {
631
+ display: inline-flex;
632
+ align-items: center;
633
+ gap: 5px;
634
+ padding: 0;
635
+ border: 0;
636
+ background: none;
637
+ color: var(--atmo-muted, inherit);
638
+ font: inherit;
639
+ font-size: 0.85em;
640
+ font-weight: 600;
641
+ text-decoration: none;
642
+ cursor: pointer;
643
+ }
644
+
645
+ :global(:where(.atmo-post-action:hover, .atmo-post-action:focus-visible)) {
646
+ color: var(--atmo-accent, #1185fe);
647
+ }
648
+
649
+ :global(:where(.atmo-post-action svg)) {
650
+ width: 16px;
651
+ height: 16px;
652
+ fill: none;
653
+ stroke: currentColor;
654
+ stroke-width: 1.8;
655
+ stroke-linecap: round;
656
+ stroke-linejoin: round;
657
+ }
658
+
659
+ :global(:where(.atmo-post-action[aria-pressed="true"])) {
660
+ color: var(--atmo-accent, #1185fe);
661
+ }
662
+
663
+ :global(:where(.atmo-post-action[data-kind="like"][aria-pressed="true"] svg)) {
664
+ fill: currentColor;
665
+ }
666
+
667
+ /* Follow / Following, in place of a "Follow me" link. */
668
+ /* Low-key: a slim outlined pill in the accent colour. */
669
+ :global(:where(.atmo-follow)) {
670
+ display: inline-flex;
671
+ align-items: center;
672
+ gap: 6px;
673
+ padding: 3px 11px;
674
+ border: 1px solid var(--atmo-accent, #1185fe);
675
+ border-radius: 999px;
676
+ background: none;
677
+ color: var(--atmo-accent, #1185fe);
678
+ font: inherit;
679
+ font-size: 0.85em;
680
+ font-weight: 600;
681
+ line-height: 1.5;
682
+ cursor: pointer;
683
+ }
684
+
685
+ :global(:where(.atmo-follow:not([aria-pressed="true"]):hover, .atmo-follow:not([aria-pressed="true"]):focus-visible)) {
686
+ background: color-mix(in srgb, var(--atmo-accent, #1185fe) 12%, transparent);
687
+ }
688
+
689
+ :global(:where(.atmo-follow svg)) {
690
+ flex: none;
691
+ width: 13px;
692
+ height: 13px;
693
+ fill: currentColor;
694
+ }
695
+
696
+ :global(:where(.atmo-follow__avatar)) {
697
+ flex: none;
698
+ width: 16px;
699
+ height: 16px;
700
+ border-radius: 50%;
701
+ object-fit: cover;
702
+ }
703
+
704
+ :global(:where(.atmo-follow[aria-pressed="true"])) {
705
+ border-color: var(--atmo-border, #8886);
706
+ color: var(--atmo-muted, inherit);
707
+ }
708
+
709
+ :global(:where(.atmo-follow:disabled)) {
710
+ opacity: 0.6;
711
+ cursor: progress;
712
+ }
713
+
714
+ /* The signed-in reader's avatar and its menu. */
715
+ :global(:where(.atmo-account)) {
716
+ position: relative;
717
+ display: inline-block;
718
+ }
719
+
720
+ :global(:where(.atmo-account__button)) {
721
+ display: grid;
722
+ place-items: center;
723
+ width: 32px;
724
+ height: 32px;
725
+ padding: 0;
726
+ overflow: hidden;
727
+ border: 1px solid var(--atmo-border, #8886);
728
+ border-radius: 50%;
729
+ background: var(--atmo-field-bg, #8882);
730
+ color: inherit;
731
+ font: inherit;
732
+ font-weight: 700;
733
+ cursor: pointer;
734
+ }
735
+
736
+ :global(:where(.atmo-account__button img)) {
737
+ width: 100%;
738
+ height: 100%;
739
+ object-fit: cover;
740
+ }
741
+
742
+ :global(:where(.atmo-account__menu)) {
743
+ position: absolute;
744
+ z-index: 20;
745
+ top: calc(100% + 8px);
746
+ right: 0;
747
+ display: grid;
748
+ gap: 10px;
749
+ min-width: 200px;
750
+ padding: 12px 14px;
751
+ border: 1px solid var(--atmo-border, #8886);
752
+ border-radius: var(--atmo-radius, 10px);
753
+ background: var(--atmo-menu-bg, Canvas);
754
+ color: var(--atmo-text, CanvasText);
755
+ box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
756
+ text-align: left;
757
+ }
758
+
759
+ :global(:where(.atmo-account__menu[hidden])) {
760
+ display: none;
761
+ }
762
+
763
+ :global(:where(.atmo-account__who)) {
764
+ margin: 0;
765
+ color: var(--atmo-muted, inherit);
766
+ font-size: 0.85em;
767
+ overflow-wrap: anywhere;
768
+ }
769
+
770
+ :global(:where(.atmo-account__who strong)) {
771
+ color: var(--atmo-text, inherit);
772
+ }
773
+
774
+ :global(:where(.atmo-account__sign-out)) {
775
+ justify-self: start;
776
+ padding: 6px 12px;
777
+ border: 1px solid var(--atmo-border, #8886);
778
+ border-radius: var(--atmo-radius, 10px);
779
+ background: none;
780
+ color: inherit;
781
+ font: inherit;
782
+ font-size: 0.9em;
783
+ cursor: pointer;
784
+ }
785
+ </style>