astro-atmosphere-comments 0.4.1 → 0.5.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +21 -23
  3. package/components/AtmosphereComments.astro +186 -83
  4. package/components/AtmosphereReactions.astro +86 -51
  5. package/components/SignInForm.astro +17 -4
  6. package/components/atmosphere.css +21 -0
  7. package/index.mjs +42 -32
  8. package/lib/client/account.ts +553 -0
  9. package/lib/client/composer.ts +7 -10
  10. package/lib/http.ts +28 -0
  11. package/lib/permissions.ts +108 -0
  12. package/lib/public-settings.ts +18 -0
  13. package/lib/thread.ts +36 -6
  14. package/lib/uris.ts +31 -0
  15. package/locales/de.json +20 -4
  16. package/locales/en.json +20 -4
  17. package/locales/es.json +20 -4
  18. package/locales/fr.json +20 -4
  19. package/locales/it.json +20 -4
  20. package/locales/ja.json +20 -4
  21. package/locales/nl.json +20 -4
  22. package/locales/pt.json +20 -4
  23. package/package.json +2 -7
  24. package/routes/callback.astro +42 -0
  25. package/routes/client-metadata.json.ts +3 -2
  26. package/routes/following.ts +28 -0
  27. package/routes/identity.ts +69 -0
  28. package/routes/media.ts +7 -10
  29. package/routes/post.ts +1 -1
  30. package/routes/profile.ts +20 -0
  31. package/routes/richtext.ts +30 -0
  32. package/routes/thread.ts +23 -3
  33. package/routes/typeahead.ts +7 -23
  34. package/virtual.d.ts +5 -0
  35. package/bin/cli.mjs +0 -31
  36. package/lib/oauth.ts +0 -110
  37. package/lib/reader.ts +0 -130
  38. package/lib/store.ts +0 -46
  39. package/lib/visitor.ts +0 -42
  40. package/routes/callback.ts +0 -30
  41. package/routes/comment.ts +0 -97
  42. package/routes/delete.ts +0 -41
  43. package/routes/follow.ts +0 -71
  44. package/routes/jwks.json.ts +0 -13
  45. package/routes/login.ts +0 -58
  46. package/routes/logout.ts +0 -20
  47. package/routes/me.ts +0 -12
  48. package/routes/react.ts +0 -48
  49. package/routes/state.ts +0 -24
@@ -71,8 +71,9 @@ const messages = messagesFor(lang, {
71
71
  });
72
72
  const t = (key: string, vars?: Record<string, string | number>) => translate(messages, lang, key, vars);
73
73
  const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
74
+ // Links lead off the page, so they open in a new tab.
74
75
  const link = (href: string | undefined, text: string, attributes = "") =>
75
- `<a${href ? ` href="${escapeHtml(href)}"` : ""}${attributes}>${escapeHtml(text)}</a>`;
76
+ `<a${href ? ` href="${escapeHtml(href)}"` : ""} target="_blank" rel="noopener"${attributes}>${escapeHtml(text)}</a>`;
76
77
  // The announcement on bsky.app, when the page is given it (otherwise the
77
78
  // script fills it in once the thread has loaded).
78
79
  const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
@@ -120,6 +121,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
120
121
  </button>
121
122
  </div>
122
123
 
124
+ {/* A sign-in from before posting was asked for: one more permission. */}
125
+ <div class="atmo__permission" data-permission hidden>
126
+ <p data-permission-text>{t("composer.needsPermission")}</p>
127
+ <p class="atmo__hint">{t("permission.approve")}</p>
128
+ <div class="atmo__row">
129
+ <button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
130
+ <button type="button" class="atmo__link-button" data-permission-sign-out>{t("composer.signOut")}</button>
131
+ </div>
132
+ </div>
133
+
123
134
  <Composer id="atmo" messages={messages} lang={lang} signOut />
124
135
  </div>
125
136
 
@@ -142,6 +153,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
142
153
  import { translate } from "../lib/translate";
143
154
  import { enhanceHandleBox } from "../lib/client/handle-suggestions";
144
155
  import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
156
+ import { handleSignInForm, me as whoIsSignedIn, send, signIn, signOut, state } from "../lib/client/account";
157
+ import type { Permission } from "../lib/permissions";
158
+ import { authorOf } from "../lib/uris";
145
159
 
146
160
 
147
161
  // The error and status codes the routes and the sign-in round trip send,
@@ -154,7 +168,6 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
154
168
  "signed-out": "messages.signedOut",
155
169
  "needs-permission": "messages.needsPermission",
156
170
  "not-allowed": "messages.notAllowed",
157
- "new-permissions": "messages.newPermissions",
158
171
  "too-long": "messages.tooLong",
159
172
  "too-many": "messages.tooMany",
160
173
  error: "messages.error",
@@ -176,23 +189,32 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
176
189
  const signedOut = $<HTMLElement>("[data-view='signed-out']");
177
190
  const signedIn = $<HTMLElement>("[data-view='signed-in']");
178
191
  const composer = $<HTMLFormElement>("[data-composer]");
192
+ const permissionPanel = $<HTMLElement>("[data-permission]");
179
193
  // A clean copy of the comment box, for reply boxes under comments.
180
194
  const template = composer.cloneNode(true) as HTMLFormElement;
181
195
  const loginForm = $<HTMLFormElement>("[data-login-form]");
182
196
  const status = $<HTMLElement>("[data-status]");
183
197
  let isSignedIn = false;
184
- let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
198
+ let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
199
+ const can = (permission: Permission) => !!me.can?.includes(permission);
185
200
  // What the main box is doing: a comment on the post, or a quote post.
186
201
  let mainMode: "comment" | "quote" = "comment";
187
202
 
188
203
  const say = (message: string) => (status.textContent = message);
189
- const send = (path: string, body: unknown) =>
190
- fetch(`${base}/${path}`, {
191
- method: "POST",
192
- credentials: "same-origin",
193
- headers: { "content-type": "application/json" },
194
- body: JSON.stringify(body),
195
- });
204
+ // Sends the reader to their account host for one more permission,
205
+ // coming back to `hash` on this page (to finish what they asked).
206
+ const askPermission = (permission: Permission, hash = "#atmosphere-comments") =>
207
+ signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
208
+ // Says what the permission is for before going (the account host may
209
+ // ask the reader to sign in again first, which is puzzling unexplained),
210
+ // with Continue to approve. Delete and Hide do the same in their panels.
211
+ const explainPermission = (permission: "post" | "react") => {
212
+ const go = Object.assign(make("button", "atmo__link-button", t("comment.continue")), { type: "button" });
213
+ go.addEventListener("click", () => void askPermission(permission));
214
+ status.replaceChildren(`${t(`permission.${permission}`)} ${t("permission.approve")} `, go);
215
+ status.scrollIntoView({ block: "nearest", behavior: "smooth" });
216
+ go.focus({ preventScroll: true });
217
+ };
196
218
  // Elements this script creates get the component's marker attribute, so
197
219
  // Astro's scoped styles still reach them.
198
220
  const scoped = <T extends HTMLElement>(element: T): T => {
@@ -215,6 +237,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
215
237
  depth: number;
216
238
  likeCount: number;
217
239
  repostCount: number;
240
+ withheld?: true;
218
241
  };
219
242
  type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
220
243
  let thread: Thread | null = null;
@@ -254,7 +277,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
254
277
  group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
255
278
  const faces = make("ul", "atmo-facepile");
256
279
  for (const person of people) {
257
- const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener" });
280
+ const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener", target: "_blank" });
258
281
  link.setAttribute("aria-label", nameOf(person));
259
282
  link.append(avatarOf(person, "atmo-facepile__avatar"));
260
283
  const item = make("li");
@@ -271,18 +294,29 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
271
294
  const replyElement = (reply: Reply) => {
272
295
  const item = make("li", `atmo-comment${reply.byAuthor ? " atmo-comment--author" : ""}${reply.depth ? " atmo-comment--nested" : ""}`);
273
296
  if (reply.depth) item.style.setProperty("--depth", String(Math.min(reply.depth, 3)));
297
+ // Its author asks not to be shown to logged-out people: a placeholder,
298
+ // keeping the replies under it in place. (No AT URI, so no actions.)
299
+ if (reply.withheld) {
300
+ item.classList.add("atmo-comment--withheld");
301
+ const article = make("article");
302
+ article.append(make("p", "atmo-comment__withheld", t("comment.withheld")));
303
+ item.append(article);
304
+ return item;
305
+ }
274
306
  item.dataset.atmosphereUri = reply.uri;
307
+ item.dataset.atmosphereUrl = reply.url;
275
308
  item.dataset.atmosphereAuthor = nameOf(reply.author);
276
309
  item.dataset.atmosphereLikes = String(reply.likeCount);
277
310
  item.dataset.atmosphereReposts = String(reply.repostCount);
278
311
  const author = Object.assign(make("a", "atmo-comment__author", nameOf(reply.author)), {
279
312
  href: reply.author.url,
280
313
  rel: "ugc nofollow noopener",
314
+ target: "_blank",
281
315
  });
282
316
  const who = make("div", "atmo-comment__who");
283
317
  who.append(author);
284
318
  if (reply.byAuthor) who.append(make("span", "atmo-comment__badge", authorLabel));
285
- const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener" });
319
+ const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener", target: "_blank" });
286
320
  const time = Object.assign(make("time"), { dateTime: reply.createdAt, textContent: reply.createdAt ? dates.format(new Date(reply.createdAt)) : "" });
287
321
  date.append(time, make("span", "", ` · ${t("comment.viewOnBluesky")}`));
288
322
  const names = make("div");
@@ -293,7 +327,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
293
327
  const paragraph = make("p");
294
328
  for (const piece of reply.text) {
295
329
  paragraph.append(
296
- piece.href ? Object.assign(make("a", "", piece.text), { href: piece.href, rel: "ugc nofollow noopener" }) : piece.text,
330
+ piece.href ? Object.assign(make("a", "", piece.text), { href: piece.href, rel: "ugc nofollow noopener", target: "_blank" }) : piece.text,
297
331
  );
298
332
  }
299
333
  body.append(paragraph);
@@ -383,6 +417,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
383
417
  const openLogin = () => loginForm.querySelector("input")?.focus({ preventScroll: true });
384
418
 
385
419
  enhanceHandleBox(loginForm, base);
420
+ handleSignInForm(loginForm, lang);
386
421
 
387
422
  // An avatar picture, or the name's first letter if there isn't one.
388
423
  const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
@@ -393,15 +428,17 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
393
428
  img.onload = () => slot.replaceChildren(img);
394
429
  };
395
430
 
396
- const handleError = (error: string, tell: (message: string) => void = say) => {
397
- if (error === "signed-out" || error === "new-permissions") void show(false);
431
+ const handleError = (error: string, tell: (message: string) => void = say, permission?: string) => {
432
+ if (error === "signed-out") void show(false);
433
+ // The page checks before it starts, so this is rare: ask now.
434
+ if (error === "needs-permission" && (permission === "post" || permission === "react")) return explainPermission(permission);
398
435
  tell(message(error));
399
436
  };
400
437
 
401
438
  const postedMessage = (where: HTMLElement, data: Posted) =>
402
439
  where.replaceChildren(
403
440
  `${t(data.quote ? "posted.quote" : "posted.comment")} `,
404
- Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
441
+ Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see"), target: "_blank", rel: "noopener noreferrer" }),
405
442
  );
406
443
 
407
444
  // The comment box (lib/client/composer.ts): posts as a reply to the
@@ -451,6 +488,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
451
488
  main.textarea.focus();
452
489
  });
453
490
  $<HTMLButtonElement>("[data-quote]").addEventListener("click", () => {
491
+ if (!can("post")) return explainPermission("post");
454
492
  setMainMode("quote");
455
493
  main.textarea.focus();
456
494
  });
@@ -535,7 +573,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
535
573
  } catch (problem) {
536
574
  button.setAttribute("aria-pressed", String(!on));
537
575
  setCount(before);
538
- handleError((problem as { error?: string })?.error ?? "error");
576
+ const { error, permission } = (problem ?? {}) as { error?: string; permission?: string };
577
+ handleError(error ?? "error", say, permission);
539
578
  }
540
579
  });
541
580
  return button;
@@ -557,6 +596,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
557
596
  reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
558
597
  reply.addEventListener("click", () => {
559
598
  if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
599
+ if (!can("post")) return explainPermission("post");
560
600
  openReply(comment, author);
561
601
  });
562
602
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
@@ -581,7 +621,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
581
621
  this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
582
622
  return;
583
623
  }
584
- if (data.error === "needs-permission") return askDeletePermission(comment, panel);
624
+ // The page checks first, so this is rare: ask now.
625
+ if (data.error === "needs-permission") return void askPermission("delete", `#atmo-delete=${encodeURIComponent(uri)}`);
585
626
  panel.remove();
586
627
  handleError(data.error ?? "error");
587
628
  } catch {
@@ -590,58 +631,112 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
590
631
  }
591
632
  };
592
633
 
593
- // Deleting posts isn't part of signing in, so the first delete asks the
594
- // reader's account host for it, then carries on when they come back.
595
- const askDeletePermission = (comment: HTMLElement, panel: HTMLElement) => {
596
- const form = Object.assign(document.createElement("form"), { method: "post", action: `${base}/login` });
597
- const field = (name: string, value: string) =>
598
- form.append(Object.assign(document.createElement("input"), { type: "hidden", name, value }));
599
- field("handle", me.did!);
600
- field("permission", "delete");
601
- field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
602
- const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
603
- const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
604
- cancel.addEventListener("click", () => panel.remove());
605
- form.append(go, cancel);
606
- panel.replaceChildren(
607
- make(
608
- "span",
609
- "atmo-comment-delete__text",
610
- t("comment.deletePermission"),
611
- ),
612
- form,
613
- );
614
- go.focus();
634
+ // Hide, for the announcement's own author: hides a comment here and on
635
+ // Bluesky. Showing it again is done in the Bluesky app.
636
+ const isOwner = () => !!me.did && !!post && authorOf(post) === me.did;
637
+
638
+ const hideComment = async (comment: HTMLElement, panel: HTMLElement) => {
639
+ const uri = comment.dataset.atmosphereUri!;
640
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.hiding")));
641
+ try {
642
+ const response = await send("hide", { post: uri, root: post });
643
+ const data = (await response.json()) as { error?: string };
644
+ if (response.ok) {
645
+ comment.remove();
646
+ say(t("comment.hidden"));
647
+ this.dispatchEvent(new CustomEvent("atmosphere:hidden", { detail: { uri }, bubbles: true }));
648
+ return;
649
+ }
650
+ if (data.error === "needs-permission") return void askPermission("moderate", `#atmo-hide=${encodeURIComponent(uri)}`);
651
+ panel.remove();
652
+ handleError(data.error ?? "error");
653
+ } catch {
654
+ panel.remove();
655
+ say(message("error"));
656
+ }
615
657
  };
616
658
 
617
- const deleteButton = (comment: HTMLElement) => {
618
- const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
619
- button.setAttribute("aria-label", t("comment.deleteLabel"));
620
- button.addEventListener("click", () => {
621
- const actions = button.parentElement!;
622
- if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
623
- const panel = make("div", "atmo-comment-delete");
624
- const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
625
- const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
626
- yes.addEventListener("click", () => deleteComment(comment, panel));
627
- no.addEventListener("click", () => {
628
- panel.remove();
629
- button.focus();
630
- });
631
- panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
632
- panel.setAttribute("role", "group");
633
- panel.setAttribute("aria-label", t("comment.deleteLabel"));
659
+ // Delete and Hide each need one more permission than signing in gives.
660
+ // Readers who haven't given it yet are asked for it first; once they
661
+ // have (straight away, or back from approving), they confirm.
662
+ const STEPS = {
663
+ delete: {
664
+ permission: "delete",
665
+ action: "comment.delete",
666
+ label: "comment.deleteLabel",
667
+ confirm: "comment.deleteConfirm",
668
+ needs: "comment.deletePermission",
669
+ // "{{link}}" in the wording: the comment on Bluesky, to delete it there instead.
670
+ elsewhere: "comment.deleteElsewhere",
671
+ run: deleteComment,
672
+ },
673
+ hide: {
674
+ permission: "moderate",
675
+ action: "comment.hide",
676
+ label: "comment.hideLabel",
677
+ confirm: "comment.hideConfirm",
678
+ needs: "comment.hidePermission",
679
+ elsewhere: "",
680
+ run: hideComment,
681
+ },
682
+ } as const;
683
+
684
+ const openPanel = (comment: HTMLElement, kind: keyof typeof STEPS) => {
685
+ const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
686
+ if (!actions || actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
687
+ const step = STEPS[kind];
688
+ const panel = make("div", "atmo-comment-delete");
689
+ panel.setAttribute("role", "group");
690
+ panel.setAttribute("aria-label", t(step.label));
691
+ const button = (text: string, danger = false) =>
692
+ Object.assign(make("button", `atmo-comment-action${danger ? " atmo-comment-action--danger" : ""}`, text), { type: "button" });
693
+ const no = button(t("comment.cancel"));
694
+ no.addEventListener("click", () => {
695
+ panel.remove();
696
+ actions.querySelector<HTMLElement>(`.atmo-comment-action--${kind}`)?.focus();
697
+ });
698
+ if (can(step.permission)) {
699
+ const yes = button(t(step.action), true);
700
+ yes.addEventListener("click", () => step.run(comment, panel));
701
+ panel.append(make("span", "atmo-comment-delete__text", t(step.confirm)), yes, no);
634
702
  actions.after(panel);
635
703
  no.focus();
636
- });
704
+ } else {
705
+ const go = button(t("comment.continue"), true);
706
+ go.addEventListener("click", () =>
707
+ void askPermission(step.permission, `#atmo-${kind}=${encodeURIComponent(comment.dataset.atmosphereUri!)}`),
708
+ );
709
+ const text = make("span", "atmo-comment-delete__text");
710
+ const [before, after = ""] = t(step.needs, { link: "\u0000" }).split("\u0000");
711
+ text.append(before);
712
+ if (step.elsewhere) {
713
+ const url = comment.dataset.atmosphereUrl;
714
+ text.append(
715
+ url
716
+ ? Object.assign(make("a", "", t(step.elsewhere)), { href: url, target: "_blank", rel: "noopener noreferrer" })
717
+ : t(step.elsewhere),
718
+ after,
719
+ );
720
+ }
721
+ panel.append(text, go, no);
722
+ actions.after(panel);
723
+ go.focus();
724
+ }
725
+ };
726
+
727
+ const panelButton = (comment: HTMLElement, kind: keyof typeof STEPS) => {
728
+ const button = Object.assign(make("button", `atmo-comment-action atmo-comment-action--${kind}`, t(STEPS[kind].action)), { type: "button" });
729
+ button.setAttribute("aria-label", t(STEPS[kind].label));
730
+ button.addEventListener("click", () => openPanel(comment, kind));
637
731
  return button;
638
732
  };
639
733
 
640
734
  const addDeleteButtons = () => {
641
735
  document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]").forEach((comment) => {
642
736
  const actions = comment.querySelector<HTMLElement>(":scope .atmo-comment-actions");
643
- if (!actions || actions.querySelector(".atmo-comment-action--delete")) return;
644
- if (isSignedIn && isMine(comment)) actions.append(deleteButton(comment));
737
+ if (!actions || actions.querySelector(".atmo-comment-action--delete, .atmo-comment-action--hide")) return;
738
+ if (isSignedIn && isMine(comment)) actions.append(panelButton(comment, "delete"));
739
+ else if (isSignedIn && isOwner()) actions.append(panelButton(comment, "hide"));
645
740
  });
646
741
  };
647
742
 
@@ -651,14 +746,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
651
746
  const comments = [...document.querySelectorAll<HTMLElement>("[data-atmosphere-uri]")];
652
747
  const uris = [post, ...comments.map((comment) => comment.dataset.atmosphereUri!)];
653
748
  try {
654
- const query = uris.map((uri) => `post=${encodeURIComponent(uri)}`).join("&");
655
- const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
656
- const state = (await response.json()) as {
749
+ const response = await state(uris);
750
+ const found = (await response.json()) as {
657
751
  error?: string;
658
752
  posts?: Record<string, { liked: boolean; reposted: boolean }>;
659
753
  };
660
- if (!response.ok) return handleError(state.error ?? "error");
661
- const posts = state.posts ?? {};
754
+ if (!response.ok) return handleError(found.error ?? "error");
755
+ const posts = found.posts ?? {};
662
756
  known = { ...known, ...posts };
663
757
  setPressed("like", !!posts[post]?.liked);
664
758
  setPressed("repost", !!posts[post]?.reposted);
@@ -687,23 +781,28 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
687
781
  signedOut.hidden = signed;
688
782
  signedIn.hidden = !signed;
689
783
  if (!signed) {
690
- document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-delete").forEach((part) => part.remove());
784
+ document.querySelectorAll(".atmo-comment-action--delete, .atmo-comment-action--hide, .atmo-comment-delete").forEach((part) => part.remove());
691
785
  return closeReplies();
692
786
  }
787
+ // A sign-in without posting (from an older version, which asked for
788
+ // less): one more permission, asked for before they type.
789
+ composer.hidden = !can("post");
790
+ permissionPanel.hidden = can("post");
791
+ // Which account, as they may have more than one.
792
+ $<HTMLElement>("[data-permission-text]").textContent = t("composer.needsPermission", { handle: handle ? `@${handle}` : "" });
693
793
  addCommentActions();
794
+ addDeleteButtons();
694
795
  await loadState();
695
- // Back from approving the delete permission: finish the delete they asked for.
696
- const pending = location.hash.match(/^#atmo-delete=(.+)$/);
796
+ // Back from approving the delete or hide permission: ask them to
797
+ // confirm the delete or hide they started.
798
+ const pending = location.hash.match(/^#atmo-(delete|hide)=(.+)$/);
697
799
  if (pending) {
698
800
  history.replaceState(history.state, "", `${location.pathname}${location.search}#atmosphere-comments`);
699
- const uri = decodeURIComponent(pending[1]);
700
- const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`);
701
- const actions = comment?.querySelector<HTMLElement>(":scope .atmo-comment-actions");
702
- if (comment && actions && isMine(comment)) {
703
- const panel = make("div", "atmo-comment-delete");
704
- actions.after(panel);
801
+ const kind = pending[1] as keyof typeof STEPS;
802
+ const comment = document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(decodeURIComponent(pending[2]))}"]`);
803
+ if (comment && (kind === "delete" ? isMine(comment) : isOwner())) {
705
804
  comment.scrollIntoView({ block: "center" });
706
- await deleteComment(comment, panel);
805
+ openPanel(comment, kind);
707
806
  }
708
807
  }
709
808
  };
@@ -718,11 +817,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
718
817
  }
719
818
 
720
819
  this.classList.add("is-loading");
721
- fetch(`${base}/me`, { credentials: "same-origin" })
722
- .then((response) => response.json())
723
- .then((found: { signedIn: boolean; did?: string; handle?: string; displayName?: string; avatar?: string }) => {
820
+ whoIsSignedIn()
821
+ .then((found) => {
724
822
  me = found;
725
- return show(found.signedIn, found.handle, found.displayName, found.avatar);
823
+ return found.signedIn ? show(true, found.handle, found.displayName, found.avatar) : show(false);
726
824
  })
727
825
  .catch(() => show(false))
728
826
  .finally(() => {
@@ -749,7 +847,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
749
847
  const response = await send("react", { post, kind, on });
750
848
  if (!response.ok) {
751
849
  setPressed(kind, !on);
752
- return handleError((await response.json()).error);
850
+ const { error, permission } = (await response.json()) as { error?: string; permission?: string };
851
+ return handleError(error ?? "error", say, permission);
753
852
  }
754
853
  say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
755
854
  showReaction(kind, on);
@@ -766,12 +865,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
766
865
  });
767
866
  }
768
867
 
769
- $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", async () => {
770
- await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
868
+ const signOutHere = async () => {
869
+ await signOut();
870
+ me = {};
771
871
  void show(false);
772
872
  setMainMode("comment");
773
873
  say(t("status.signedOut"));
774
- });
874
+ };
875
+ $<HTMLButtonElement>("[data-sign-out]").addEventListener("click", signOutHere);
876
+ $<HTMLButtonElement>("[data-permission-sign-out]").addEventListener("click", signOutHere);
877
+ $<HTMLButtonElement>("[data-permission-continue]").addEventListener("click", () => void askPermission("post"));
775
878
  }
776
879
  }
777
880