astro-atmosphere-comments 0.1.1 → 0.3.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.
@@ -1,19 +1,22 @@
1
1
  ---
2
- // The comment box under a post: "Respond with your Atmosphere Account", then,
3
- // once signed in, Like, Repost and Quote buttons and a box to write a
2
+ // The comment box under a post: a form to sign in with an Atmosphere account
3
+ // (Bluesky or any other), then, once signed in, Like, Repost and Quote buttons and a box to write a
4
4
  // comment (a reply to the post's announcement, or to one of its comments).
5
5
  //
6
6
  // Props:
7
7
  // post the announcement's AT URI (at://did/app.bsky.feed.post/rkey).
8
- // label the small words on the button above "Atmosphere Account"
9
- // (default "Respond with your").
8
+ // label the sign-in form's heading
9
+ // (default "Sign in to leave a comment, like or repost").
10
+ // blueskyLink "Rather not sign in here? Reply on Bluesky instead →" under the
11
+ // sign-in form (default true; false hides it).
10
12
  // credit a small "Powered by Atmosphere Comments for Astro" link under the
11
13
  // comments (default true; false hides it).
12
14
  //
13
15
  // Quote shows a preview of the reader's post with the announcement quoted
14
16
  // inside, avatars and link-card picture included (passed on by this site).
15
17
  //
16
- // Typing @ and the start of a handle suggests accounts to mention.
18
+ // Typing @ and the start of a handle suggests accounts to mention, and typing
19
+ // in the sign-in box suggests matching accounts to sign in with.
17
20
  //
18
21
  // Comments: under every element on the page with a `data-atmosphere-uri`
19
22
  // attribute (a comment's AT URI) the component adds Reply, Like and Repost.
@@ -30,6 +33,8 @@
30
33
  // can show it straight away.
31
34
  import logo from "../assets/union.svg?url";
32
35
  import settings from "../lib/settings";
36
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
37
+ import { escapeHtml } from "../lib/translate";
33
38
 
34
39
  interface Props {
35
40
  /**
@@ -38,80 +43,127 @@ interface Props {
38
43
  * is used to find it.
39
44
  */
40
45
  post?: string;
46
+ /** The sign-in form's heading. */
41
47
  label?: string;
42
48
  /** The badge on comments by the site's own accounts. */
43
49
  authorLabel?: string;
44
50
  /** A small "Powered by Atmosphere Comments for Astro" link under the comments. Default true. */
45
51
  credit?: boolean;
52
+ /** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
53
+ blueskyLink?: boolean;
54
+ /** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
55
+ locale?: string;
56
+ /** Your own wording for any line, by its key in locales/en.json. */
57
+ messages?: Record<string, string>;
46
58
  }
47
59
 
48
- const { post = "", label = "Respond with your", authorLabel = "Author", credit = true } = Astro.props;
60
+ const { post = "", label, authorLabel, credit = true, blueskyLink = true } = Astro.props;
61
+ // The wording: the language asked for (or Astro's i18n locale for the page),
62
+ // with the site's own lines on top. The script gets the same, as JSON.
63
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
64
+ const messages = messagesFor(lang, {
65
+ ...settings.messages,
66
+ ...Astro.props.messages,
67
+ ...(label && { "signIn.heading": label }),
68
+ ...(authorLabel && { "comment.author": authorLabel }),
69
+ });
70
+ const t = (key: string, vars?: Record<string, string | number>) => translate(messages, lang, key, vars);
71
+ const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
72
+ const link = (href: string | undefined, text: string, attributes = "") =>
73
+ `<a${href ? ` href="${escapeHtml(href)}"` : ""}${attributes}>${escapeHtml(text)}</a>`;
74
+ // The announcement on bsky.app, when the page is given it (otherwise the
75
+ // script fills it in once the thread has loaded).
76
+ const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
77
+ const blueskyUrl = atUri
78
+ ? `https://bsky.app/profile/${atUri[1]}/post/${atUri[2]}`
79
+ : /^https:\/\/bsky\.app\/profile\/[^/]+\/post\/[^/]+$/.test(post)
80
+ ? post
81
+ : "";
49
82
  const CREDIT_URL = "https://saltedlolly.com/atmosphere-comments-for-astro/";
50
83
  const { base } = settings;
51
84
  const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
52
85
  ---
53
86
 
54
- <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-author-label={authorLabel}>
87
+ <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
55
88
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
56
89
 
57
90
  <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
58
- <button type="button" class="atmo__badge-button" data-open-login aria-expanded="false" aria-controls="atmo-login" hidden>
59
- <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
60
- <span class="atmo__badge-text">
61
- <span class="atmo__badge-small">{label}</span>
62
- <span class="atmo__badge-big">Atmosphere Account</span>
63
- </span>
64
- </button>
65
91
  <form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
66
- <label for="atmo-handle">Sign in to leave a comment, like or repost</label>
67
- <p class="atmo__hint">Any <a href="https://atmosphereaccount.com">Atmosphere account</a> works, including <a href="https://bsky.app">Bluesky</a>: one account for a whole network of open apps.</p>
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
+ />
68
100
  <div class="atmo__row">
69
- <input
101
+ <span class="atmo__handle">
102
+ <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
103
+ <input
70
104
  id="atmo-handle"
71
105
  name="handle"
72
106
  type="text"
73
107
  inputmode="url"
74
- autocomplete="username"
108
+ data-1p-ignore
109
+ data-lpignore="true"
110
+ data-bwignore
111
+ data-form-type="other"
75
112
  autocapitalize="none"
76
113
  spellcheck="false"
77
114
  placeholder="alice.bsky.social"
78
115
  required
79
- />
116
+ />
117
+ <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label={t("signIn.matchingAccounts")} data-handle-suggestions hidden></ul>
118
+ </span>
80
119
  <input type="hidden" name="returnTo" value={returnTo} />
81
- <button type="submit" class="atmo__button">Continue</button>
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>
82
124
  </div>
83
- <p class="atmo__hint">
84
- You'll approve this on your account's own site (Bluesky's, for most people); your password
85
- is never shared here.
86
- No account yet? <a href="https://atmosphereaccount.com">Create a free Atmosphere account</a>.
87
- </p>
125
+ <p
126
+ class="atmo__hint"
127
+ set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
128
+ />
88
129
  </form>
130
+ {blueskyLink && (
131
+ <p
132
+ class="atmo__elsewhere"
133
+ data-elsewhere
134
+ hidden={!blueskyUrl}
135
+ set:html={th("elsewhere.text", {
136
+ // u-syndication: microformats readers learn this is the post's copy on Bluesky.
137
+ link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
138
+ })}
139
+ />
140
+ )}
89
141
  </div>
90
142
 
91
143
  <div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
92
- <div class="atmo__actions" role="group" aria-label="Respond to this post">
144
+ <div class="atmo__actions" role="group" aria-label={t("actions.label")}>
93
145
  <button type="button" class="atmo__action" data-react="like" aria-pressed="false">
94
146
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" /></svg>
95
- <span data-label-off>Like</span><span data-label-on>Liked</span>
147
+ <span data-label-off>{t("actions.like")}</span><span data-label-on>{t("actions.liked")}</span>
96
148
  </button>
97
149
  <button type="button" class="atmo__action" data-react="repost" aria-pressed="false">
98
150
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8h10l-3-3M17 16H7l3 3M5 13V9.5A1.5 1.5 0 0 1 6.5 8M19 11v3.5a1.5 1.5 0 0 1-1.5 1.5" /></svg>
99
- <span data-label-off>Repost</span><span data-label-on>Reposted</span>
151
+ <span data-label-off>{t("actions.repost")}</span><span data-label-on>{t("actions.reposted")}</span>
100
152
  </button>
101
153
  <button type="button" class="atmo__action" data-quote>
102
154
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
103
- <span>Quote</span>
155
+ <span>{t("actions.quote")}</span>
104
156
  </button>
105
157
  </div>
106
158
 
107
159
  <form class="atmo__composer" data-composer>
108
160
  <p class="atmo__context" data-context hidden>
109
161
  <span data-context-text></span>
110
- <button type="button" class="atmo__link-button" data-cancel-context>Cancel</button>
162
+ <button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
111
163
  </p>
112
164
  <label for="atmo-text" class="atmo__as">
113
165
  <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
114
- <span><span data-verb>Comment</span> as <strong data-handle></strong></span>
166
+ <span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
115
167
  </label>
116
168
  <div class="atmo__field">
117
169
  <textarea
@@ -122,14 +174,14 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
122
174
  aria-autocomplete="list"
123
175
  aria-controls="atmo-suggestions"
124
176
  aria-expanded="false"></textarea>
125
- <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label="Accounts to mention" data-suggestions hidden></ul>
177
+ <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label={t("composer.mentions")} data-suggestions hidden></ul>
126
178
  </div>
127
179
  <figure class="atmo__preview" data-preview hidden>
128
- <figcaption>How your post will look</figcaption>
180
+ <figcaption>{t("composer.previewCaption")}</figcaption>
129
181
  <div class="atmo__preview-post">
130
182
  <span class="atmo__avatar" data-preview-avatar aria-hidden="true"></span>
131
183
  <div class="atmo__preview-body">
132
- <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· now</span></p>
184
+ <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
133
185
  <p class="atmo__preview-text" data-preview-text></p>
134
186
  <div class="atmo__quoted">
135
187
  <p class="atmo__preview-author">
@@ -151,8 +203,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
151
203
  </figure>
152
204
  <div class="atmo__row atmo__row--end">
153
205
  <span class="atmo__count" data-count aria-live="polite"></span>
154
- <button type="button" class="atmo__link-button" data-sign-out>Sign out</button>
155
- <button type="submit" class="atmo__button" data-submit>Post comment</button>
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>
156
208
  </div>
157
209
  </form>
158
210
  </div>
@@ -168,27 +220,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
168
220
  <slot />
169
221
 
170
222
  {credit && (
171
- <p class="atmo__credit" data-credit data-pagefind-ignore>
172
- Powered by <a href={CREDIT_URL}>Atmosphere Comments for Astro</a>
173
- </p>
223
+ <p class="atmo__credit" data-credit data-pagefind-ignore set:html={th("credit", { link: link(CREDIT_URL, "Atmosphere Comments for Astro") })} />
174
224
  )}
175
225
  </atmosphere-comments>
176
226
 
177
227
  <script>
228
+ import { translate } from "../lib/translate";
229
+
178
230
  const MAX = 300;
179
231
  const graphemes = new Intl.Segmenter();
180
232
  const count = (text: string) => [...graphemes.segment(text.trim())].length;
181
233
 
182
- const MESSAGES: Record<string, string> = {
183
- "signed-in": "You're signed in. You can comment, like or repost above.",
184
- "sign-in-failed": "Signing in didn't finish. Please try again.",
185
- "account-not-found": "We couldn't find that account. Check the handle and try again.",
186
- "invalid-handle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
187
- "signed-out": "You've been signed out. Please sign in again.",
188
- "new-permissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
189
- "too-long": `Posts can be up to ${MAX} characters.`,
190
- "too-many": "You're going quickly. Please wait a minute and try again.",
191
- error: "That didn't work. Please try again.",
234
+ // The error and status codes the routes and the sign-in round trip send,
235
+ // and the wording for each (in locales/*.json).
236
+ const MESSAGE_KEYS: Record<string, string> = {
237
+ "signed-in": "messages.signedIn",
238
+ "sign-in-failed": "messages.signInFailed",
239
+ "account-not-found": "messages.accountNotFound",
240
+ "invalid-handle": "messages.invalidHandle",
241
+ "signed-out": "messages.signedOut",
242
+ "needs-permission": "messages.needsPermission",
243
+ "not-allowed": "messages.notAllowed",
244
+ "new-permissions": "messages.newPermissions",
245
+ "too-long": "messages.tooLong",
246
+ "too-many": "messages.tooMany",
247
+ error: "messages.error",
192
248
  };
193
249
 
194
250
  type Posted = { uri: string; url: string; quote: boolean };
@@ -197,6 +253,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
197
253
  class AtmosphereComments extends HTMLElement {
198
254
  connectedCallback() {
199
255
  const base = this.dataset.base ?? "/atmosphere";
256
+ // The wording, in the page's language, from the server.
257
+ const lang = this.dataset.locale || "en";
258
+ const wording = JSON.parse(this.dataset.messages || "{}") as Record<string, string>;
259
+ const t = (key: string, vars?: Record<string, string | number>) => translate(wording, lang, key, vars);
260
+ const message = (code: string) => t(MESSAGE_KEYS[code] ?? MESSAGE_KEYS.error, { max: MAX });
200
261
  // The announcement's AT URI. A bsky.app address or the page's
201
262
  // Standard.site record is turned into one when the thread loads.
202
263
  let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
@@ -206,7 +267,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
206
267
  const composer = $<HTMLFormElement>("[data-composer]");
207
268
  // A clean copy of the comment box, for reply boxes under comments.
208
269
  const template = composer.cloneNode(true) as HTMLFormElement;
209
- const loginButton = $<HTMLButtonElement>("[data-open-login]");
210
270
  const loginForm = $<HTMLFormElement>("[data-login-form]");
211
271
  const status = $<HTMLElement>("[data-status]");
212
272
  let isSignedIn = false;
@@ -245,13 +305,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
245
305
  likeCount: number;
246
306
  repostCount: number;
247
307
  };
248
- type Thread = { uri: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
308
+ type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
249
309
  let thread: Thread | null = null;
250
310
  const reactionsBox = $<HTMLElement>("[data-reactions]");
251
311
  const repliesList = $<HTMLOListElement>("[data-replies]");
252
- const authorLabel = this.dataset.authorLabel || "Author";
312
+ const authorLabel = t("comment.author");
253
313
  const nameOf = (person: Pick<Person, "handle" | "displayName">) => person.displayName || `@${person.handle}`;
254
- const dates = new Intl.DateTimeFormat(document.documentElement.lang || undefined, {
314
+ const dates = new Intl.DateTimeFormat(lang, {
255
315
  day: "numeric",
256
316
  month: "long",
257
317
  year: "numeric",
@@ -280,7 +340,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
280
340
  ...groups.map(({ kind, count, people }) => {
281
341
  const group = make("div", "atmo-reactions__group");
282
342
  group.dataset.reaction = kind;
283
- group.append(make("p", "atmo-reactions__count", `${count} ${kind}${count === 1 ? "" : "s"}`));
343
+ group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
284
344
  const faces = make("ul", "atmo-facepile");
285
345
  for (const person of people) {
286
346
  const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener" });
@@ -313,7 +373,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
313
373
  if (reply.byAuthor) who.append(make("span", "atmo-comment__badge", authorLabel));
314
374
  const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener" });
315
375
  const time = Object.assign(make("time"), { dateTime: reply.createdAt, textContent: reply.createdAt ? dates.format(new Date(reply.createdAt)) : "" });
316
- date.append(time, make("span", "", " · view on Bluesky"));
376
+ date.append(time, make("span", "", ` · ${t("comment.viewOnBluesky")}`));
317
377
  const names = make("div");
318
378
  names.append(who, date);
319
379
  const header = make("header", "atmo-comment__header");
@@ -359,6 +419,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
359
419
  }
360
420
  thread = data.thread;
361
421
  post = thread.uri;
422
+ const elsewhere = this.querySelector<HTMLElement>("[data-elsewhere]");
423
+ if (elsewhere && thread.url) {
424
+ elsewhere.querySelector("a")!.href = thread.url;
425
+ elsewhere.hidden = false;
426
+ }
362
427
  renderReactions();
363
428
  repliesList.replaceChildren(...thread.replies.map(replyElement));
364
429
  } catch {
@@ -404,19 +469,100 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
404
469
  renderReactions();
405
470
  };
406
471
 
407
- // With JavaScript, the handle form opens from the Atmosphere button.
408
- loginButton.hidden = false;
409
- loginForm.hidden = true;
410
- const openLogin = () => {
411
- loginForm.hidden = false;
412
- loginButton.setAttribute("aria-expanded", "true");
413
- loginForm.querySelector("input")?.focus();
414
- };
415
- loginButton.addEventListener("click", () => {
416
- if (loginForm.hidden) return openLogin();
417
- loginForm.hidden = true;
418
- loginButton.setAttribute("aria-expanded", "false");
419
- });
472
+ const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
473
+
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
+ }
420
566
 
421
567
  // An avatar picture, or the name's first letter if there isn't one.
422
568
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -429,13 +575,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
429
575
 
430
576
  const handleError = (error: string, tell: (message: string) => void = say) => {
431
577
  if (error === "signed-out" || error === "new-permissions") void show(false);
432
- tell(MESSAGES[error] ?? MESSAGES.error);
578
+ tell(message(error));
433
579
  };
434
580
 
435
581
  const postedMessage = (where: HTMLElement, data: Posted) =>
436
582
  where.replaceChildren(
437
- data.quote ? "Your quote post is on Bluesky. " : "Your comment is posted. ",
438
- Object.assign(document.createElement("a"), { href: data.url, textContent: "See it on Bluesky →" }),
583
+ `${t(data.quote ? "posted.quote" : "posted.comment")} `,
584
+ Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
439
585
  );
440
586
 
441
587
  // Everything a comment box does: counting characters, @mention
@@ -456,7 +602,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
456
602
 
457
603
  const updateCount = () => {
458
604
  const used = count(textarea.value);
459
- counter.textContent = used > MAX - 50 ? `${MAX - used} left` : "";
605
+ counter.textContent = used > MAX - 50 ? t("composer.charactersLeft", { count: MAX - used }) : "";
460
606
  counter.classList.toggle("is-over", used > MAX);
461
607
  };
462
608
 
@@ -563,9 +709,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
563
709
  event.preventDefault();
564
710
  const text = textarea.value.trim();
565
711
  if (!text) return;
566
- if (count(text) > MAX) return behaviour.tell(MESSAGES["too-long"]);
712
+ if (count(text) > MAX) return behaviour.tell(message("too-long"));
567
713
  submit.disabled = true;
568
- behaviour.tell("Posting…");
714
+ behaviour.tell(t("composer.posting"));
569
715
  try {
570
716
  const response = await send("comment", { text, root: post, ...behaviour.extra() });
571
717
  const data = await response.json();
@@ -576,7 +722,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
576
722
  showPosted(data);
577
723
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
578
724
  } catch {
579
- behaviour.tell(MESSAGES.error);
725
+ behaviour.tell(message("error"));
580
726
  } finally {
581
727
  submit.disabled = false;
582
728
  }
@@ -592,7 +738,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
592
738
  let quotedLoaded = false;
593
739
  const updatePreviewText = () => {
594
740
  const words = main.textarea.value.trim();
595
- previewText.textContent = words || "Your words appear here.";
741
+ previewText.textContent = words || t("composer.previewEmpty");
596
742
  previewText.classList.toggle("is-placeholder", !words);
597
743
  };
598
744
  const loadQuoted = async () => {
@@ -634,9 +780,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
634
780
  mainMode = next;
635
781
  const quoting = next === "quote";
636
782
  $<HTMLElement>("[data-context]").hidden = !quoting;
637
- $<HTMLElement>("[data-context-text]").textContent = quoting ? "Quoting this post on your own account" : "";
638
- $<HTMLElement>("[data-verb]").textContent = quoting ? "Quote" : "Comment";
639
- $<HTMLElement>("[data-submit]").textContent = quoting ? "Post quote" : "Post comment";
783
+ $<HTMLElement>("[data-context-text]").textContent = quoting ? t("composer.quoting") : "";
784
+ $<HTMLElement>("[data-verb]").textContent = t(quoting ? "composer.quote" : "composer.comment");
785
+ $<HTMLElement>("[data-submit]").textContent = t(quoting ? "composer.postQuote" : "composer.postComment");
640
786
  preview.hidden = !quoting;
641
787
  if (quoting) void loadQuoted();
642
788
  };
@@ -677,8 +823,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
677
823
  suggestions.id = `${id}-suggestions`;
678
824
  textarea.setAttribute("aria-controls", suggestions.id);
679
825
  form.querySelector<HTMLElement>("[data-context]")!.hidden = false;
680
- form.querySelector("[data-context-text]")!.textContent = `Replying to ${author}`;
681
- form.querySelector("[data-verb]")!.textContent = "Reply";
826
+ form.querySelector("[data-context-text]")!.textContent = t("composer.replyingTo", { name: author });
827
+ form.querySelector("[data-verb]")!.textContent = t("comment.reply");
682
828
  form.querySelector("[data-submit]")!.textContent = "Post reply";
683
829
  form.querySelector("[data-handle]")!.textContent = me.handle ? `@${me.handle}` : "";
684
830
  setAvatar(form.querySelector<HTMLElement>("[data-me-avatar]")!, me.displayName || me.handle || "?", me.avatar);
@@ -717,8 +863,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
717
863
  button.dataset.kind = kind;
718
864
  const seen = known[comment.dataset.atmosphereUri!];
719
865
  button.setAttribute("aria-pressed", String(!!(kind === "like" ? seen?.liked : seen?.reposted)));
720
- const author = comment.dataset.atmosphereAuthor || "this comment";
721
- button.setAttribute("aria-label", `${kind === "like" ? "Like" : "Repost"} ${author}'s comment`);
866
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
867
+ button.setAttribute("aria-label", t(kind === "like" ? "comment.likeLabel" : "comment.repostLabel", { name: author }));
722
868
  const count = Number(comment.dataset[kind === "like" ? "atmosphereLikes" : "atmosphereReposts"] || 0);
723
869
  button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${kind === "like" ? HEART : ARROWS}</svg><span></span>`;
724
870
  const setCount = (value: number) => {
@@ -727,7 +873,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
727
873
  };
728
874
  setCount(count);
729
875
  button.addEventListener("click", async () => {
730
- if (!isSignedIn) return signInFirst(`Sign in to ${kind} comments.`);
876
+ if (!isSignedIn) return signInFirst(t(kind === "like" ? "signInFirst.like" : "signInFirst.repost"));
731
877
  const on = button.getAttribute("aria-pressed") !== "true";
732
878
  const before = Number(button.dataset.count || 0);
733
879
  button.setAttribute("aria-pressed", String(on));
@@ -751,23 +897,103 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
751
897
  if (comment.querySelector(":scope .atmo-comment-actions")) return;
752
898
  added += 1;
753
899
  const actions = Object.assign(document.createElement("div"), { className: "atmo-comment-actions" });
754
- const author = comment.dataset.atmosphereAuthor || "this comment";
900
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
755
901
  const reply = Object.assign(document.createElement("button"), {
756
902
  type: "button",
757
903
  className: "atmo-comment-action",
758
- textContent: "Reply",
904
+ textContent: t("comment.reply"),
759
905
  });
760
- reply.setAttribute("aria-label", `Reply to ${author}`);
906
+ reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
761
907
  reply.addEventListener("click", () => {
762
- if (!isSignedIn) return signInFirst("Sign in to reply.");
908
+ if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
763
909
  openReply(comment, author);
764
910
  });
765
911
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
766
912
  (comment.querySelector("article") ?? comment).append(actions);
767
913
  });
914
+ addDeleteButtons();
768
915
  return added;
769
916
  };
770
917
 
918
+ // Delete, under the signed-in reader's own comments only.
919
+ const isMine = (comment: HTMLElement) => !!me.did && !!comment.dataset.atmosphereUri?.startsWith(`at://${me.did}/`);
920
+
921
+ const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
922
+ const uri = comment.dataset.atmosphereUri!;
923
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.deleting")));
924
+ try {
925
+ const response = await send("delete", { post: uri });
926
+ const data = (await response.json()) as { error?: string };
927
+ if (response.ok) {
928
+ comment.remove();
929
+ say(t("comment.deleted"));
930
+ this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
931
+ return;
932
+ }
933
+ if (data.error === "needs-permission") return askDeletePermission(comment, panel);
934
+ panel.remove();
935
+ handleError(data.error ?? "error");
936
+ } catch {
937
+ panel.remove();
938
+ say(message("error"));
939
+ }
940
+ };
941
+
942
+ // Deleting posts isn't part of signing in, so the first delete asks the
943
+ // reader's account host for it, then carries on when they come back.
944
+ const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
945
+ const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
946
+ const field = (name: string, value: string) =>
947
+ form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
948
+ field("handle", me.did!);
949
+ field("permission", "delete");
950
+ field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
951
+ const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
952
+ const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
953
+ cancel.addEventListener("click", () => panel.remove());
954
+ form.append(go, cancel);
955
+ panel.replaceChildren(
956
+ make(
957
+ "span",
958
+ "atmo-comment-delete__text",
959
+ t("comment.deletePermission"),
960
+ ),
961
+ form,
962
+ );
963
+ go.focus();
964
+ };
965
+
966
+ const deleteButton = (comment: HTMLElement) => {
967
+ const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
968
+ button.setAttribute("aria-label", t("comment.deleteLabel"));
969
+ button.addEventListener("click", () => {
970
+ const actions = button.parentElement!;
971
+ if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
972
+ const panel = make("div", "atmo-comment-delete");
973
+ const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
974
+ const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
975
+ yes.addEventListener("click", () => deleteComment(comment, panel));
976
+ no.addEventListener("click", () => {
977
+ panel.remove();
978
+ button.focus();
979
+ });
980
+ panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
981
+ panel.setAttribute("role", "group");
982
+ panel.setAttribute("aria-label", t("comment.deleteLabel"));
983
+ actions.after(panel);
984
+ no.focus();
985
+ });
986
+ return button;
987
+ };
988
+
989
+ const addDeleteButtons = () => {
990
+ document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
991
+ const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
992
+ if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
993
+ if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
994
+ });
995
+ };
996
+
771
997
  // Which of the post and its comments the reader has already liked or reposted.
772
998
  const loadState = async () => {
773
999
  if (!post) return;
@@ -809,16 +1035,33 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
809
1035
  isSignedIn = signed;
810
1036
  signedOut.hidden = signed;
811
1037
  signedIn.hidden = !signed;
812
- if (!signed) return closeReplies();
1038
+ if (!signed) {
1039
+ document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
1040
+ return closeReplies();
1041
+ }
813
1042
  addCommentActions();
814
1043
  await loadState();
1044
+ // Back from approving the delete permission: finish the delete they asked for.
1045
+ const pending = location.hash.match(/^#atmo-delete=(.+)$/);
1046
+ if (pending) {
1047
+ history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
1048
+ const uri = decodeURIComponent(pending[1]);
1049
+ const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
1050
+ const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
1051
+ if (comment && actions && isMine(comment)) {
1052
+ const panel = make("div", "atmo-comment-delete");
1053
+ actions.after(panel);
1054
+ comment.scrollIntoView({ block: "center" });
1055
+ await deleteComment(comment, panel);
1056
+ }
1057
+ }
815
1058
  };
816
1059
 
817
1060
  // A message from the sign-in round trip (?atmosphere=…), then tidy the address.
818
1061
  const url = new URL(location.href);
819
1062
  const result = url.searchParams.get("atmosphere");
820
1063
  if (result) {
821
- say(MESSAGES[result] ?? "");
1064
+ say(MESSAGE_KEYS[result] ? message(result) : "");
822
1065
  url.searchParams.delete("atmosphere");
823
1066
  history.replaceState(history.state, "", url);
824
1067
  }
@@ -857,7 +1100,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
857
1100
  setPressed(kind, !on);
858
1101
  return handleError((await response.json()).error);
859
1102
  }
860
- say(on ? (kind === "like" ? "Liked." : "Reposted.") : kind === "like" ? "Like removed." : "Repost removed.");
1103
+ say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
861
1104
  showReaction(kind, on);
862
1105
  this.dispatchEvent(
863
1106
  new CustomEvent("atmosphere:reacted", {
@@ -867,7 +1110,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
867
1110
  );
868
1111
  } catch {
869
1112
  setPressed(kind, !on);
870
- say(MESSAGES.error);
1113
+ say(message("error"));
871
1114
  }
872
1115
  });
873
1116
  }
@@ -876,7 +1119,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
876
1119
  await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
877
1120
  void show(false);
878
1121
  setMainMode("comment");
879
- say("You're signed out.");
1122
+ say(t("status.signedOut"));
880
1123
  });
881
1124
  }
882
1125
  }
@@ -900,50 +1143,49 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
900
1143
  display: none !important;
901
1144
  }
902
1145
 
903
- /* The button follows the Atmosphere Account sign-in badge: the logo, then
904
- two lines of monospaced text, in a rounded outline. */
905
- .atmo__badge-button {
906
- display: inline-flex;
907
- align-items: center;
908
- gap: 12px;
909
- padding: 9px 16px 9px 12px;
910
- border: 2px solid currentColor;
911
- border-radius: 12px;
912
- background: none;
913
- color: var(--atmo-text, currentColor);
914
- font-family: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
915
- text-align: left;
916
- cursor: pointer;
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;
917
1154
  }
918
1155
 
919
- .atmo__badge-button:hover,
920
- .atmo__badge-button:focus-visible,
921
- .atmo__badge-button[aria-expanded="true"] {
922
- color: var(--atmo-accent, #1185fe);
1156
+ /* Matching accounts sit under the handle box, as wide as it. */
1157
+ .atmo__handle .atmo__suggestions {
1158
+ width: 100%;
923
1159
  }
924
1160
 
925
- /* The logo is single-colour, so it is drawn in the text colour. */
926
- .atmo__logo {
927
- flex: none;
928
- width: 40px;
929
- height: 40px;
930
- background-color: currentColor;
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);
931
1169
  mask: var(--atmo-logo) center / contain no-repeat;
1170
+ pointer-events: none;
932
1171
  }
933
1172
 
934
- .atmo__badge-text {
935
- display: grid;
936
- line-height: 1.15;
1173
+ .atmo .atmo__handle input[type="text"] {
1174
+ padding-left: 40px;
937
1175
  }
938
1176
 
939
- .atmo__badge-small {
940
- font-size: 13px;
1177
+ .atmo__button--sign-in {
1178
+ display: inline-flex;
1179
+ align-items: center;
1180
+ gap: 8px;
941
1181
  }
942
1182
 
943
- .atmo__badge-big {
944
- font-size: 19px;
945
- font-weight: 700;
946
- white-space: nowrap;
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;
947
1189
  }
948
1190
 
949
1191
  /* The comment box when it sits under a comment being replied to. */
@@ -955,6 +1197,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
955
1197
  margin-top: 10px;
956
1198
  }
957
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
+
958
1210
  .atmo__login,
959
1211
  .atmo__composer {
960
1212
  display: grid;
@@ -1111,7 +1363,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1111
1363
  opacity: 0.8;
1112
1364
  }
1113
1365
 
1114
- .atmo__credit a {
1366
+ .atmo__credit :global(a) {
1115
1367
  color: inherit;
1116
1368
  }
1117
1369
 
@@ -1459,6 +1711,24 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1459
1711
  margin-top: 8px;
1460
1712
  }
1461
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
+
1462
1732
  :global(.atmo-comment-action) {
1463
1733
  display: inline-flex;
1464
1734
  align-items: center;
@@ -1473,6 +1743,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1473
1743
  cursor: pointer;
1474
1744
  }
1475
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
+
1476
1755
  :global(.atmo-comment-action:hover),
1477
1756
  :global(.atmo-comment-action:focus-visible),
1478
1757
  :global(.atmo-comment-action[aria-pressed="true"]) {