astro-atmosphere-comments 0.4.2 → 0.6.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.
- package/CHANGELOG.md +40 -0
- package/README.md +36 -27
- package/components/AtmosphereComments.astro +378 -117
- package/components/AtmosphereReactions.astro +145 -98
- package/components/Composer.astro +5 -2
- package/components/SignInForm.astro +65 -7
- package/components/atmosphere.css +267 -2
- package/index.d.ts +11 -0
- package/index.mjs +53 -32
- package/lib/client/account.ts +661 -0
- package/lib/client/composer.ts +23 -10
- package/lib/client/subscribe.ts +106 -0
- package/lib/http.ts +28 -0
- package/lib/permissions.ts +132 -0
- package/lib/public-settings.ts +20 -0
- package/lib/quoted.ts +4 -0
- package/lib/settings.ts +2 -0
- package/lib/thread.ts +36 -6
- package/lib/uris.ts +39 -0
- package/locales/de.json +20 -4
- package/locales/en.json +48 -13
- package/locales/es.json +20 -4
- package/locales/fr.json +20 -4
- package/locales/it.json +20 -4
- package/locales/ja.json +20 -4
- package/locales/nl.json +20 -4
- package/locales/pt.json +20 -4
- package/package.json +2 -7
- package/routes/callback.astro +42 -0
- package/routes/client-metadata.json.ts +3 -2
- package/routes/following.ts +28 -0
- package/routes/identity.ts +69 -0
- package/routes/media.ts +7 -10
- package/routes/post.ts +2 -1
- package/routes/profile.ts +20 -0
- package/routes/richtext.ts +30 -0
- package/routes/thread.ts +23 -3
- package/routes/typeahead.ts +7 -23
- package/virtual.d.ts +5 -0
- package/bin/cli.mjs +0 -31
- package/lib/oauth.ts +0 -110
- package/lib/reader.ts +0 -130
- package/lib/store.ts +0 -46
- package/lib/visitor.ts +0 -42
- package/routes/callback.ts +0 -30
- package/routes/comment.ts +0 -97
- package/routes/delete.ts +0 -41
- package/routes/follow.ts +0 -71
- package/routes/jwks.json.ts +0 -13
- package/routes/login.ts +0 -58
- package/routes/logout.ts +0 -20
- package/routes/me.ts +0 -12
- package/routes/react.ts +0 -48
- package/routes/state.ts +0 -24
|
@@ -15,9 +15,17 @@
|
|
|
15
15
|
// A link marked `data-atmosphere-follow="<DID>"` (and
|
|
16
16
|
// `data-atmosphere-follow-handle="you.bsky.social"`), such as "Follow me on
|
|
17
17
|
// Bluesky", becomes a Follow / Following button for signed-in readers; a
|
|
18
|
-
// second press unfollows.
|
|
18
|
+
// second press unfollows. Only
|
|
19
19
|
// `allowedAuthors` can be followed through your site.
|
|
20
20
|
//
|
|
21
|
+
// With the integration's `standardSite` option, a button marked
|
|
22
|
+
// `data-atmosphere-subscribe` (and `hidden`, as it needs JavaScript) becomes
|
|
23
|
+
// Subscribe / ✓ Subscribed for the site's publication. Anyone not signed in is
|
|
24
|
+
// offered the sign-in form first, and subscribed once they're back. Its label
|
|
25
|
+
// goes in an element inside it marked `data-atmosphere-subscribe-label`;
|
|
26
|
+
// without one it keeps its own content (style it by `aria-pressed`).
|
|
27
|
+
// (AtmosphereComments has its own panel for this under each post.)
|
|
28
|
+
//
|
|
21
29
|
// Signed-in readers like and repost from your page. Anyone else who presses
|
|
22
30
|
// Like or Repost is offered the sign-in form in an overlay, and the like or
|
|
23
31
|
// repost is done once they're back. Only posts by the integration's
|
|
@@ -25,7 +33,8 @@
|
|
|
25
33
|
//
|
|
26
34
|
// Props: locale, messages (as for AtmosphereComments).
|
|
27
35
|
import settings from "../lib/settings";
|
|
28
|
-
import { messagesFor, pickLocale, translate } from "../lib/i18n";
|
|
36
|
+
import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
|
|
37
|
+
import { escapeHtml } from "../lib/translate";
|
|
29
38
|
import SignInForm from "./SignInForm.astro";
|
|
30
39
|
import Composer from "./Composer.astro";
|
|
31
40
|
import "./atmosphere.css";
|
|
@@ -47,8 +56,27 @@ const { base } = settings;
|
|
|
47
56
|
<dialog class="atmo atmo-signin" aria-label={t("dialog.heading")} data-signin-dialog>
|
|
48
57
|
<button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
|
|
49
58
|
<SignInForm id="atmo-dialog" returnTo={Astro.url.pathname} messages={messages} lang={lang} heading={t("dialog.heading")} />
|
|
59
|
+
<p
|
|
60
|
+
class="atmo__elsewhere"
|
|
61
|
+
data-dialog-elsewhere
|
|
62
|
+
hidden
|
|
63
|
+
set:html={translateHtml(messages, lang, "elsewhere.text", {
|
|
64
|
+
link: `<a target="_blank" rel="noopener" class="atmo__elsewhere-link">${escapeHtml(t("elsewhere.link"))}</a>`,
|
|
65
|
+
})}
|
|
66
|
+
/>
|
|
50
67
|
<p class="atmo-signin__status" role="status" data-dialog-status></p>
|
|
51
68
|
</dialog>
|
|
69
|
+
{/* Before asking the reader's account host for one more permission: what it's for. */}
|
|
70
|
+
<dialog class="atmo atmo-signin atmo-permission" aria-labelledby="atmo-permission-heading" data-permission-dialog>
|
|
71
|
+
<button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
|
|
72
|
+
<p class="atmo-permission__heading" id="atmo-permission-heading">{t("permission.heading")}</p>
|
|
73
|
+
<p data-permission-text></p>
|
|
74
|
+
<p class="atmo__hint">{t("permission.approve")}</p>
|
|
75
|
+
<div class="atmo__row">
|
|
76
|
+
<button type="button" class="atmo__button" data-permission-continue>{t("comment.continue")}</button>
|
|
77
|
+
<button type="button" class="atmo__link-button" data-close>{t("comment.cancel")}</button>
|
|
78
|
+
</div>
|
|
79
|
+
</dialog>
|
|
52
80
|
{/* Reply or Quote, for signed-in readers: the comment box in an overlay. */}
|
|
53
81
|
<dialog class="atmo atmo-signin atmo-compose" aria-label={t("postActions.replyLabel")} data-compose-dialog>
|
|
54
82
|
<button type="button" class="atmo-signin__close" aria-label={t("dialog.close")} data-close>×</button>
|
|
@@ -65,6 +93,9 @@ const { base } = settings;
|
|
|
65
93
|
import { translate } from "../lib/translate";
|
|
66
94
|
import { enhanceHandleBox } from "../lib/client/handle-suggestions";
|
|
67
95
|
import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
|
|
96
|
+
import { following, handleSignInForm, me as whoIsSignedIn, send as act, signIn, signOut as endSignIn, state } from "../lib/client/account";
|
|
97
|
+
import { addSubscribeButtons, finishPendingSubscribe, loadSubscribed } from "../lib/client/subscribe";
|
|
98
|
+
import type { Permission } from "../lib/permissions";
|
|
68
99
|
|
|
69
100
|
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
101
|
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" />';
|
|
@@ -83,7 +114,6 @@ const { base } = settings;
|
|
|
83
114
|
// Error codes from the routes, and their wording (as in the comments).
|
|
84
115
|
const MESSAGE_KEYS: Record<string, string> = {
|
|
85
116
|
"signed-out": "messages.signedOut",
|
|
86
|
-
"new-permissions": "messages.newPermissions",
|
|
87
117
|
"not-allowed": "messages.notAllowed",
|
|
88
118
|
"too-long": "messages.tooLong",
|
|
89
119
|
"too-many": "messages.tooMany",
|
|
@@ -101,11 +131,30 @@ const { base } = settings;
|
|
|
101
131
|
const status = dialog.querySelector<HTMLElement>("[data-dialog-status]")!;
|
|
102
132
|
let signedIn = false;
|
|
103
133
|
let handle = "";
|
|
104
|
-
let me: { did?: string; handle?: string; displayName?: string; avatar?: string } = {};
|
|
134
|
+
let me: { did?: string; handle?: string; displayName?: string; avatar?: string; can?: string[] } = {};
|
|
135
|
+
const can = (permission: Permission) => !!me.can?.includes(permission);
|
|
136
|
+
// One more permission from the reader's account host, then back to
|
|
137
|
+
// `hash` on this page to finish what they pressed. An overlay says
|
|
138
|
+
// what it's for first: the host may ask them to sign in again before
|
|
139
|
+
// it shows what's asked for, which is puzzling without warning.
|
|
140
|
+
const permissionDialog = this.querySelector<HTMLDialogElement>("[data-permission-dialog]")!;
|
|
141
|
+
let pendingPermission: { permission: Permission; hash: string } | null = null;
|
|
142
|
+
const askPermission = (permission: Permission, hash: string, vars: Record<string, string> = {}) => {
|
|
143
|
+
pendingPermission = { permission, hash };
|
|
144
|
+
permissionDialog.querySelector("[data-permission-text]")!.textContent = t(`permission.${permission}`, vars);
|
|
145
|
+
if (!permissionDialog.open) permissionDialog.showModal();
|
|
146
|
+
permissionDialog.querySelector<HTMLButtonElement>("[data-permission-continue]")!.focus();
|
|
147
|
+
};
|
|
148
|
+
permissionDialog.querySelector("[data-permission-continue]")!.addEventListener("click", () => {
|
|
149
|
+
if (!pendingPermission) return;
|
|
150
|
+
const { permission, hash } = pendingPermission;
|
|
151
|
+
void signIn(me.did!, { permissions: [permission], returnTo: `${location.pathname}${location.search}${hash}`, lang });
|
|
152
|
+
});
|
|
105
153
|
// What the reader has liked or reposted, by post.
|
|
106
154
|
const known: Record<string, { liked: boolean; reposted: boolean }> = {};
|
|
107
155
|
|
|
108
156
|
enhanceHandleBox(form, base);
|
|
157
|
+
handleSignInForm(form, lang);
|
|
109
158
|
for (const overlay of this.querySelectorAll<HTMLDialogElement>("dialog")) {
|
|
110
159
|
overlay.querySelectorAll("[data-close]").forEach((button) => button.addEventListener("click", () => overlay.close()));
|
|
111
160
|
// A click on the dimmed page around the overlay closes it.
|
|
@@ -150,7 +199,11 @@ const { base } = settings;
|
|
|
150
199
|
this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
|
|
151
200
|
},
|
|
152
201
|
onError: (code, tell) => {
|
|
153
|
-
if (code === "signed-out"
|
|
202
|
+
if (code === "signed-out") signedIn = false;
|
|
203
|
+
if (code === "needs-permission") {
|
|
204
|
+
compose.close();
|
|
205
|
+
return void askPermission("post", `#atmo-compose=${composeMode}:${encodeURIComponent(composeRoot)}`);
|
|
206
|
+
}
|
|
154
207
|
tell(message(code));
|
|
155
208
|
},
|
|
156
209
|
onInput: () => quote.update(),
|
|
@@ -197,22 +250,29 @@ const { base } = settings;
|
|
|
197
250
|
return element;
|
|
198
251
|
};
|
|
199
252
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
253
|
+
// The sign-in overlay: says what it's for, and comes back to `hash`,
|
|
254
|
+
// where what the reader pressed is finished.
|
|
255
|
+
const dialogHeading = form.querySelector("label")!;
|
|
256
|
+
const dialogElsewhere = dialog.querySelector<HTMLElement>("[data-dialog-elsewhere]")!;
|
|
257
|
+
const openSignIn = (heading: string, hash: string, problem = "", blueskyUrl = "") => {
|
|
258
|
+
dialogHeading.textContent = heading;
|
|
259
|
+
dialog.setAttribute("aria-label", heading);
|
|
260
|
+
form.querySelector<HTMLInputElement>("[data-return-to]")!.value = `${location.pathname}${location.search}${hash}`;
|
|
204
261
|
status.textContent = problem;
|
|
262
|
+
dialogElsewhere.hidden = !blueskyUrl;
|
|
263
|
+
if (blueskyUrl) dialogElsewhere.querySelector("a")!.href = blueskyUrl;
|
|
205
264
|
if (!dialog.open) dialog.showModal();
|
|
206
265
|
form.querySelector<HTMLInputElement>('input[name="handle"]')!.focus();
|
|
207
266
|
};
|
|
267
|
+
const blueskyUrlOf = (uri: string) => posts().find((post) => post.dataset.atmospherePost === uri)?.dataset.atmosphereUrl ?? "";
|
|
268
|
+
const SIGN_IN_HEADINGS = { like: "signInFirst.likePost", repost: "signInFirst.repostPost", reply: "signInFirst.reply", quote: "signInFirst.quotePost" };
|
|
269
|
+
// Not signed in: the overlay, coming back to finish this like or repost.
|
|
270
|
+
const askToSignIn = (kind: Kind, uri: string, problem = "") =>
|
|
271
|
+
openSignIn(t(SIGN_IN_HEADINGS[kind]), `#atmo-react=${kind}:${encodeURIComponent(uri)}`, problem, blueskyUrlOf(uri));
|
|
272
|
+
// The same for Reply and Quote, coming back to the comment box.
|
|
273
|
+
const askToSignInToCompose = (mode: "reply" | "quote", uri: string, problem = "") =>
|
|
274
|
+
openSignIn(t(SIGN_IN_HEADINGS[mode]), `#atmo-compose=${mode}:${encodeURIComponent(uri)}`, problem, blueskyUrlOf(uri));
|
|
208
275
|
|
|
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
276
|
|
|
217
277
|
const icon = (paths: string) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
|
|
218
278
|
|
|
@@ -228,7 +288,7 @@ const { base } = settings;
|
|
|
228
288
|
button.setAttribute("aria-pressed", String(on));
|
|
229
289
|
setCount(button, Math.max(0, before + (on ? 1 : -1)));
|
|
230
290
|
try {
|
|
231
|
-
const response = await
|
|
291
|
+
const response = await act("react", { post: uri, kind, on });
|
|
232
292
|
if (response.ok) {
|
|
233
293
|
known[uri] = { ...(known[uri] ?? { liked: false, reposted: false }), [kind === "like" ? "liked" : "reposted"]: on };
|
|
234
294
|
return;
|
|
@@ -236,10 +296,12 @@ const { base } = settings;
|
|
|
236
296
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
|
237
297
|
button.setAttribute("aria-pressed", String(!on));
|
|
238
298
|
setCount(button, before);
|
|
239
|
-
|
|
299
|
+
// Signed in to comment before likes and reposts were asked for.
|
|
300
|
+
if (data.error === "needs-permission") return void askPermission("react", `#atmo-react=${kind}:${encodeURIComponent(uri)}`);
|
|
301
|
+
if (data.error === "signed-out") {
|
|
240
302
|
signedIn = false;
|
|
241
303
|
labelButton(button, kind);
|
|
242
|
-
askToSignIn(kind, uri, t(
|
|
304
|
+
askToSignIn(kind, uri, t("messages.signedOut"));
|
|
243
305
|
}
|
|
244
306
|
} catch {
|
|
245
307
|
button.setAttribute("aria-pressed", String(!on));
|
|
@@ -278,7 +340,8 @@ const { base } = settings;
|
|
|
278
340
|
};
|
|
279
341
|
|
|
280
342
|
// Reply and Quote: the comment box in an overlay for signed-in readers,
|
|
281
|
-
// otherwise the
|
|
343
|
+
// otherwise the sign-in overlay. ⌘-click or a middle click opens the post
|
|
344
|
+
// on Bluesky.
|
|
282
345
|
// Icons only, as on Bluesky, with their names for screen readers and as
|
|
283
346
|
// tooltips.
|
|
284
347
|
const bskyLink = (post: HTMLElement, paths: string) => {
|
|
@@ -291,8 +354,11 @@ const { base } = settings;
|
|
|
291
354
|
labelLink(link);
|
|
292
355
|
link.innerHTML = icon(paths);
|
|
293
356
|
link.addEventListener("click", (event) => {
|
|
294
|
-
if (
|
|
357
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
295
358
|
event.preventDefault();
|
|
359
|
+
if (!signedIn) return askToSignInToCompose(mode, post.dataset.atmospherePost!);
|
|
360
|
+
// A sign-in without posting (from an older version): asked for first.
|
|
361
|
+
if (!can("post")) return void askPermission("post", `#atmo-compose=${mode}:${encodeURIComponent(post.dataset.atmospherePost!)}`);
|
|
296
362
|
openCompose(post, mode);
|
|
297
363
|
});
|
|
298
364
|
return link;
|
|
@@ -323,8 +389,7 @@ const { base } = settings;
|
|
|
323
389
|
const uris = posts().map((post) => post.dataset.atmospherePost!);
|
|
324
390
|
if (!signedIn || !uris.length) return;
|
|
325
391
|
try {
|
|
326
|
-
const
|
|
327
|
-
const response = await fetch(`${base}/state?${query}`, { credentials: "same-origin" });
|
|
392
|
+
const response = await state(uris);
|
|
328
393
|
if (!response.ok) return;
|
|
329
394
|
const state = ((await response.json()) as { posts?: typeof known }).posts ?? {};
|
|
330
395
|
Object.assign(known, state);
|
|
@@ -345,17 +410,8 @@ const { base } = settings;
|
|
|
345
410
|
const followButtons = new Map<string, { following: boolean; set: (on: boolean) => void; press: () => Promise<void> }>();
|
|
346
411
|
// Following isn't part of signing in: the first follow asks the
|
|
347
412
|
// reader's account host for it, then carries on when they're back.
|
|
348
|
-
const askFollowPermission = (subject: string) =>
|
|
349
|
-
|
|
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
|
-
};
|
|
413
|
+
const askFollowPermission = (subject: string, who: string) =>
|
|
414
|
+
askPermission("follow", `#atmo-follow=${encodeURIComponent(subject)}`, { handle: who });
|
|
359
415
|
const setUpFollow = async () => {
|
|
360
416
|
for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
|
|
361
417
|
const subject = link.dataset.atmosphereFollow!;
|
|
@@ -365,19 +421,19 @@ const { base } = settings;
|
|
|
365
421
|
const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
|
|
366
422
|
const button = make("button", "atmo-follow");
|
|
367
423
|
button.type = "button";
|
|
368
|
-
let
|
|
424
|
+
let isFollowing = false;
|
|
369
425
|
// The account's avatar, through the site (its own accounts' are always served).
|
|
370
426
|
const avatar = make("img", "atmo-follow__avatar");
|
|
371
427
|
avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
|
|
372
428
|
avatar.alt = "";
|
|
373
429
|
avatar.onerror = () => avatar.remove();
|
|
374
430
|
const render = () => {
|
|
375
|
-
button.setAttribute("aria-pressed", String(
|
|
431
|
+
button.setAttribute("aria-pressed", String(isFollowing));
|
|
376
432
|
// The butterfly, then "Follow" with the account's avatar beside its
|
|
377
433
|
// handle (wherever the language puts the handle), or the avatar
|
|
378
434
|
// with "Following".
|
|
379
435
|
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${BUTTERFLY}</svg>`;
|
|
380
|
-
if (
|
|
436
|
+
if (isFollowing) {
|
|
381
437
|
button.append(avatar, make("span", "", `✓ ${t("follow.following")}`));
|
|
382
438
|
} else {
|
|
383
439
|
const [before, after = ""] = t("follow.follow").split("{{handle}}");
|
|
@@ -385,47 +441,46 @@ const { base } = settings;
|
|
|
385
441
|
button.append(avatar, make("span", "", who));
|
|
386
442
|
if (after.trim()) button.append(make("span", "", after.trim()));
|
|
387
443
|
}
|
|
388
|
-
button.title =
|
|
444
|
+
button.title = isFollowing ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
|
|
389
445
|
button.setAttribute("aria-label", button.title);
|
|
390
446
|
};
|
|
391
447
|
const set = (on: boolean) => {
|
|
392
|
-
|
|
448
|
+
isFollowing = on;
|
|
393
449
|
render();
|
|
394
450
|
};
|
|
395
451
|
const press = async () => {
|
|
396
452
|
button.disabled = true;
|
|
397
453
|
try {
|
|
398
|
-
const response = await
|
|
399
|
-
method: "POST",
|
|
400
|
-
credentials: "same-origin",
|
|
401
|
-
headers: { "content-type": "application/json" },
|
|
402
|
-
body: JSON.stringify({ subject, on: !following }),
|
|
403
|
-
});
|
|
454
|
+
const response = await act("follow", { subject, on: !isFollowing });
|
|
404
455
|
const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
|
|
405
456
|
if (response.ok) return set(!!data.following);
|
|
406
|
-
if (data.error === "needs-permission") return askFollowPermission(subject);
|
|
407
|
-
if (data.error === "signed-out"
|
|
457
|
+
if (data.error === "needs-permission") return void askFollowPermission(subject, who);
|
|
458
|
+
if (data.error === "signed-out") location.reload();
|
|
408
459
|
} finally {
|
|
409
460
|
button.disabled = false;
|
|
410
461
|
}
|
|
411
462
|
};
|
|
412
463
|
button.addEventListener("click", () => void press());
|
|
413
|
-
followButtons.set(subject, { get following() { return
|
|
464
|
+
followButtons.set(subject, { get following() { return isFollowing; }, set, press });
|
|
414
465
|
render();
|
|
415
466
|
// The button takes the link's place. (Inline, as a site's own style
|
|
416
467
|
// for the link could otherwise keep it showing.)
|
|
417
468
|
link.hidden = true;
|
|
418
469
|
link.style.display = "none";
|
|
419
470
|
link.after(button);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (response.ok) set(!!((await response.json()) as { following?: boolean }).following);
|
|
423
|
-
} catch {
|
|
424
|
-
// Shown as Follow; pressing it still works.
|
|
425
|
-
}
|
|
471
|
+
const known = await following(subject);
|
|
472
|
+
if (known !== null) set(known);
|
|
426
473
|
}
|
|
427
474
|
};
|
|
428
475
|
|
|
476
|
+
// Subscribe buttons the site places (the comments look after their own).
|
|
477
|
+
const askToSubscribe = (problem = "") => openSignIn(t("subscribe.signIn"), "#atmo-subscribe", problem);
|
|
478
|
+
addSubscribeButtons(
|
|
479
|
+
[...document.querySelectorAll<HTMLButtonElement>("[data-atmosphere-subscribe]")].filter((button) => !button.closest("atmosphere-comments")),
|
|
480
|
+
t,
|
|
481
|
+
askToSubscribe,
|
|
482
|
+
);
|
|
483
|
+
|
|
429
484
|
// Back from approving following: follow as they asked.
|
|
430
485
|
const finishPendingFollow = async () => {
|
|
431
486
|
const pending = location.hash.match(/^#atmo-follow=(.+)$/);
|
|
@@ -464,6 +519,30 @@ const { base } = settings;
|
|
|
464
519
|
if (button && button.getAttribute("aria-pressed") !== "true") await react(post, button, kind as Kind, true);
|
|
465
520
|
};
|
|
466
521
|
|
|
522
|
+
// Back from signing in or allowing posting: open the Reply or Quote box they pressed.
|
|
523
|
+
const finishPendingCompose = async () => {
|
|
524
|
+
const pending = location.hash.match(/^#atmo-compose=(reply|quote):(.+)$/);
|
|
525
|
+
if (!pending) return;
|
|
526
|
+
const url = new URL(location.href);
|
|
527
|
+
const problem = url.searchParams.get("atmosphere") ?? "";
|
|
528
|
+
if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
|
|
529
|
+
const uri = decodeURIComponent(pending[2]);
|
|
530
|
+
const mode = pending[1] as "reply" | "quote";
|
|
531
|
+
// The page may still be loading its posts (a server island, say).
|
|
532
|
+
for (let tries = 0; tries < 40 && !posts().some((post) => post.dataset.atmospherePost === uri); tries++) {
|
|
533
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
534
|
+
}
|
|
535
|
+
history.replaceState(history.state, "", `${url.pathname}${url.search}`);
|
|
536
|
+
const post = posts().find((candidate) => candidate.dataset.atmospherePost === uri);
|
|
537
|
+
if (!post) return;
|
|
538
|
+
post.scrollIntoView({ block: "center" });
|
|
539
|
+
if (!signedIn) {
|
|
540
|
+
if (SIGN_IN_PROBLEMS[problem]) askToSignInToCompose(mode, uri, t(SIGN_IN_PROBLEMS[problem]));
|
|
541
|
+
} else if (can("post")) {
|
|
542
|
+
openCompose(post, mode);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
467
546
|
// The reader's avatar wherever the page marks data-atmosphere-account,
|
|
468
547
|
// opening a menu with who they're signed in as and Sign out.
|
|
469
548
|
const showAccount = (me: { handle?: string; displayName?: string; avatar?: string }) => {
|
|
@@ -492,7 +571,7 @@ const { base } = settings;
|
|
|
492
571
|
signOut.type = "button";
|
|
493
572
|
signOut.addEventListener("click", async () => {
|
|
494
573
|
signOut.disabled = true;
|
|
495
|
-
await
|
|
574
|
+
await endSignIn();
|
|
496
575
|
location.reload();
|
|
497
576
|
});
|
|
498
577
|
menu.append(who, signOut);
|
|
@@ -521,9 +600,8 @@ const { base } = settings;
|
|
|
521
600
|
}
|
|
522
601
|
};
|
|
523
602
|
|
|
524
|
-
|
|
525
|
-
.then((
|
|
526
|
-
.then((found: { signedIn?: boolean; handle?: string; displayName?: string; avatar?: string }) => {
|
|
603
|
+
whoIsSignedIn()
|
|
604
|
+
.then((found) => {
|
|
527
605
|
me = found;
|
|
528
606
|
signedIn = !!found.signedIn;
|
|
529
607
|
handle = found.handle ? `@${found.handle}` : "";
|
|
@@ -542,10 +620,13 @@ const { base } = settings;
|
|
|
542
620
|
addActions();
|
|
543
621
|
await loadState();
|
|
544
622
|
await finishPending();
|
|
623
|
+
await finishPendingCompose();
|
|
545
624
|
if (signedIn) {
|
|
546
625
|
await setUpFollow();
|
|
547
626
|
await finishPendingFollow();
|
|
627
|
+
await loadSubscribed();
|
|
548
628
|
}
|
|
629
|
+
await finishPendingSubscribe(t, askToSubscribe);
|
|
549
630
|
});
|
|
550
631
|
|
|
551
632
|
// Posts that arrive later (a server island, say) get buttons too.
|
|
@@ -562,38 +643,14 @@ const { base } = settings;
|
|
|
562
643
|
</script>
|
|
563
644
|
|
|
564
645
|
<style>
|
|
565
|
-
.atmo-
|
|
566
|
-
|
|
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;
|
|
646
|
+
.atmo-permission p {
|
|
647
|
+
margin: 0 0 10px;
|
|
583
648
|
}
|
|
584
649
|
|
|
585
|
-
.atmo-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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;
|
|
650
|
+
.atmo-permission__heading {
|
|
651
|
+
/* Clear of the close button. */
|
|
652
|
+
padding-right: 32px;
|
|
653
|
+
font-weight: 700;
|
|
597
654
|
}
|
|
598
655
|
|
|
599
656
|
.atmo-compose {
|
|
@@ -608,16 +665,6 @@ const { base } = settings;
|
|
|
608
665
|
margin: 0 0 12px;
|
|
609
666
|
}
|
|
610
667
|
|
|
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
668
|
/* The row under each post. Defaults carry no weight, so a site's own
|
|
622
669
|
rules always win. */
|
|
623
670
|
:global(:where(.atmo-post-actions)) {
|
|
@@ -56,7 +56,7 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
|
|
|
56
56
|
<div class="atmo__quoted">
|
|
57
57
|
<p class="atmo__preview-author">
|
|
58
58
|
<span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
|
|
59
|
-
<strong data-quoted-name></strong> <span data-quoted-handle></span>
|
|
59
|
+
<strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
|
|
60
60
|
</p>
|
|
61
61
|
<p class="atmo__preview-text" data-quoted-text></p>
|
|
62
62
|
<div class="atmo__card" data-card hidden>
|
|
@@ -64,7 +64,10 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
|
|
|
64
64
|
<div>
|
|
65
65
|
<strong data-card-title></strong>
|
|
66
66
|
<span data-card-description></span>
|
|
67
|
-
<span class="atmo__card-domain"
|
|
67
|
+
<span class="atmo__card-domain">
|
|
68
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
|
|
69
|
+
<span data-card-domain></span>
|
|
70
|
+
</span>
|
|
68
71
|
</div>
|
|
69
72
|
</div>
|
|
70
73
|
</div>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
// The sign-in form: a handle box (which suggests matching accounts, see
|
|
3
3
|
// lib/client/handle-suggestions.ts) and a "Sign in with Atmosphere" button.
|
|
4
|
+
// Signing in happens in the browser (handleSignInForm in
|
|
5
|
+
// lib/client/account.ts), so the form needs JavaScript.
|
|
4
6
|
// Used under posts by AtmosphereComments and in the sign-in overlay of
|
|
5
7
|
// AtmosphereReactions; not meant to be used on its own.
|
|
6
8
|
//
|
|
@@ -9,7 +11,10 @@
|
|
|
9
11
|
// returnTo where the reader comes back to after signing in
|
|
10
12
|
// messages, lang the wording (see lib/i18n.ts)
|
|
11
13
|
// heading the form's heading, instead of "signIn.heading"
|
|
14
|
+
// permissions more to ask for (groups in lib/permissions.ts) on top of
|
|
15
|
+
// everything readers can do here (DEFAULT_PERMISSIONS)
|
|
12
16
|
import logo from "../assets/union.svg?url";
|
|
17
|
+
import type { Permission } from "../lib/permissions";
|
|
13
18
|
import settings from "../lib/settings";
|
|
14
19
|
import { translate, translateHtml } from "../lib/i18n";
|
|
15
20
|
import { escapeHtml } from "../lib/translate";
|
|
@@ -20,10 +25,11 @@ interface Props {
|
|
|
20
25
|
messages: Record<string, string>;
|
|
21
26
|
lang: string;
|
|
22
27
|
heading?: string;
|
|
28
|
+
permissions?: Permission[];
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
const { id, returnTo, messages, lang, heading } = Astro.props;
|
|
26
|
-
const {
|
|
31
|
+
const { id, returnTo, messages, lang, heading, permissions = [] } = Astro.props;
|
|
32
|
+
const { policyUri } = settings;
|
|
27
33
|
const t = (key: string) => translate(messages, lang, key);
|
|
28
34
|
const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
|
|
29
35
|
// Links lead off the page, so they open in a new tab.
|
|
@@ -31,13 +37,14 @@ const link = (href: string, text: string) =>
|
|
|
31
37
|
`<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
|
|
32
38
|
---
|
|
33
39
|
|
|
34
|
-
<form class="atmo__login" id={`${id}-login`}
|
|
40
|
+
<form class="atmo__login" id={`${id}-login`} data-login-form>
|
|
35
41
|
<label for={`${id}-handle`}>{heading ?? t("signIn.heading")}</label>
|
|
36
42
|
<p
|
|
37
43
|
class="atmo__hint"
|
|
38
44
|
set:html={th("signIn.anyAccount", {
|
|
39
45
|
atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
|
|
40
46
|
bluesky: link("https://bsky.app", "Bluesky"),
|
|
47
|
+
createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")),
|
|
41
48
|
})}
|
|
42
49
|
/>
|
|
43
50
|
<div class="atmo__row">
|
|
@@ -67,15 +74,22 @@ const link = (href: string, text: string) =>
|
|
|
67
74
|
></ul>
|
|
68
75
|
</span>
|
|
69
76
|
<input type="hidden" name="returnTo" value={returnTo} data-return-to />
|
|
77
|
+
{permissions.map((permission) => <input type="hidden" name="permission" value={permission} />)}
|
|
70
78
|
<button type="submit" class="atmo__button atmo__button--sign-in">
|
|
71
79
|
<span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
80
|
+
<span class="atmo__spinner" aria-hidden="true"></span>
|
|
72
81
|
{t("signIn.button")}
|
|
73
82
|
</button>
|
|
74
83
|
</div>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
{/* The detail, for anyone who wants it. */}
|
|
85
|
+
<details class="atmo__details">
|
|
86
|
+
<summary>{t("signIn.more")}</summary>
|
|
87
|
+
<p class="atmo__hint">{t("signIn.privacy")}</p>
|
|
88
|
+
<p class="atmo__hint">
|
|
89
|
+
{t("signIn.access")}
|
|
90
|
+
{policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
|
|
91
|
+
</p>
|
|
92
|
+
</details>
|
|
79
93
|
</form>
|
|
80
94
|
|
|
81
95
|
<style>
|
|
@@ -94,6 +108,17 @@ const link = (href: string, text: string) =>
|
|
|
94
108
|
font-weight: 700;
|
|
95
109
|
}
|
|
96
110
|
|
|
111
|
+
.atmo__details summary {
|
|
112
|
+
width: fit-content;
|
|
113
|
+
color: var(--atmo-muted, inherit);
|
|
114
|
+
font-size: 0.85em;
|
|
115
|
+
cursor: pointer;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.atmo__details .atmo__hint {
|
|
119
|
+
margin-top: 6px;
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
.atmo__hint {
|
|
98
123
|
margin: 0;
|
|
99
124
|
color: var(--atmo-muted, inherit);
|
|
@@ -154,6 +179,39 @@ const link = (href: string, text: string) =>
|
|
|
154
179
|
cursor: pointer;
|
|
155
180
|
}
|
|
156
181
|
|
|
182
|
+
/* While signing in, a spinner takes the logo's place, so the button keeps its size. */
|
|
183
|
+
.atmo__spinner {
|
|
184
|
+
display: none;
|
|
185
|
+
flex: none;
|
|
186
|
+
box-sizing: border-box;
|
|
187
|
+
width: 20px;
|
|
188
|
+
height: 20px;
|
|
189
|
+
border: 2px solid currentColor;
|
|
190
|
+
border-right-color: transparent;
|
|
191
|
+
border-radius: 50%;
|
|
192
|
+
animation: atmo-spin 700ms linear infinite;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
[data-signing-in] .atmo__spinner {
|
|
196
|
+
display: inline-block;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
[data-signing-in] .atmo__button .atmo__logo {
|
|
200
|
+
display: none;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@keyframes atmo-spin {
|
|
204
|
+
to {
|
|
205
|
+
transform: rotate(360deg);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@media (prefers-reduced-motion: reduce) {
|
|
210
|
+
.atmo__spinner {
|
|
211
|
+
animation-duration: 2.5s;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
157
215
|
.atmo__button .atmo__logo {
|
|
158
216
|
flex: none;
|
|
159
217
|
width: 20px;
|