astro-atmosphere-comments 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +36 -6
- package/components/AtmosphereComments.astro +126 -71
- package/components/AtmosphereFollow.astro +63 -0
- package/components/AtmosphereReactions.astro +89 -28
- package/components/AtmosphereSubscribe.astro +135 -0
- package/components/Composer.astro +7 -17
- package/components/PostPreview.astro +24 -0
- package/components/SignInForm.astro +20 -6
- package/components/atmosphere.css +166 -4
- package/lib/client/account.ts +39 -0
- package/lib/client/composer.ts +52 -35
- package/lib/client/done.ts +54 -0
- package/lib/resolve.ts +2 -1
- package/lib/thread.ts +27 -9
- package/locales/de.json +17 -6
- package/locales/en.json +17 -7
- package/locales/es.json +17 -6
- package/locales/fr.json +17 -6
- package/locales/it.json +16 -5
- package/locales/ja.json +17 -6
- package/locales/nl.json +16 -5
- package/locales/pt.json +17 -6
- package/package.json +4 -2
|
@@ -92,7 +92,8 @@ const { base } = settings;
|
|
|
92
92
|
<script>
|
|
93
93
|
import { translate } from "../lib/translate";
|
|
94
94
|
import { enhanceHandleBox } from "../lib/client/handle-suggestions";
|
|
95
|
-
import { MAX, quotePreview, setUpComposer, type Posted } from "../lib/client/composer";
|
|
95
|
+
import { MAX, fetchPost, quotePreview, setUpComposer, showPost, type Posted, type ShownPost } from "../lib/client/composer";
|
|
96
|
+
import { showDone } from "../lib/client/done";
|
|
96
97
|
import { following, handleSignInForm, me as whoIsSignedIn, send as act, signIn, signOut as endSignIn, state } from "../lib/client/account";
|
|
97
98
|
import { addSubscribeButtons, finishPendingSubscribe, loadSubscribed } from "../lib/client/subscribe";
|
|
98
99
|
import type { Permission } from "../lib/permissions";
|
|
@@ -211,13 +212,45 @@ const { base } = settings;
|
|
|
211
212
|
const quote = quotePreview(composeForm, composer.textarea, composerContext);
|
|
212
213
|
$c<HTMLElement>("[data-cancel-context]").hidden = true;
|
|
213
214
|
|
|
215
|
+
// The post being replied to, shown above the box, since the reply won't
|
|
216
|
+
// appear on this page: fetched through the site once per post.
|
|
217
|
+
const parent = $c<HTMLElement>("[data-parent]");
|
|
218
|
+
const fetched = new Map<string, Promise<ShownPost | null>>();
|
|
219
|
+
const postFor = (uri: string) => {
|
|
220
|
+
if (!fetched.has(uri)) fetched.set(uri, fetchPost(base, uri));
|
|
221
|
+
return fetched.get(uri)!;
|
|
222
|
+
};
|
|
223
|
+
// A handle, never a DID: a post's address may name its author either way
|
|
224
|
+
// (Bluesky accepts both), and a lookup that fails gives back the DID.
|
|
225
|
+
const shownHandle = (handle?: string | null) =>
|
|
226
|
+
handle && !handle.startsWith("did:") && handle !== "handle.invalid" ? handle : null;
|
|
227
|
+
const replyingTo = (handle?: string | null) =>
|
|
228
|
+
t("composer.replyingTo", { name: shownHandle(handle) ? `@${handle}` : t("comment.someone") });
|
|
229
|
+
|
|
214
230
|
const openCompose = (post: HTMLElement, mode: "reply" | "quote") => {
|
|
215
231
|
composeRoot = post.dataset.atmospherePost!;
|
|
216
232
|
composeMode = mode;
|
|
217
|
-
|
|
233
|
+
// From the bsky.app address until the post itself arrives.
|
|
234
|
+
let author: string | undefined;
|
|
235
|
+
try {
|
|
236
|
+
author = decodeURIComponent(post.dataset.atmosphereUrl?.match(/\/profile\/([^/]+)\//)?.[1] ?? "");
|
|
237
|
+
} catch {
|
|
238
|
+
author = undefined;
|
|
239
|
+
}
|
|
240
|
+
const context = $c<HTMLElement>("[data-context-text]");
|
|
218
241
|
$c<HTMLElement>("[data-context]").hidden = false;
|
|
219
|
-
|
|
220
|
-
|
|
242
|
+
parent.hidden = true;
|
|
243
|
+
if (mode === "quote") context.textContent = t("composer.quoting");
|
|
244
|
+
else {
|
|
245
|
+
context.textContent = replyingTo(author);
|
|
246
|
+
const root = composeRoot;
|
|
247
|
+
void postFor(root).then((shown) => {
|
|
248
|
+
if (!shown || composeRoot !== root || composeMode !== "reply") return;
|
|
249
|
+
showPost(parent, shown, composerContext);
|
|
250
|
+
parent.hidden = false;
|
|
251
|
+
if (shownHandle(shown.author.handle)) context.textContent = replyingTo(shown.author.handle);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
221
254
|
$c<HTMLElement>("[data-verb]").textContent = t(mode === "quote" ? "composer.quote" : "comment.reply");
|
|
222
255
|
$c<HTMLElement>("[data-submit]").textContent = t(mode === "quote" ? "composer.postQuote" : "composer.postReply");
|
|
223
256
|
$c<HTMLElement>("[data-preview]").hidden = mode !== "quote";
|
|
@@ -407,7 +440,16 @@ const { base } = settings;
|
|
|
407
440
|
// Follow buttons in place of links marked data-atmosphere-follow, for
|
|
408
441
|
// signed-in readers: "Follow @handle", then "Following" (a second press
|
|
409
442
|
// unfollows, as on Bluesky, without inviting it).
|
|
410
|
-
|
|
443
|
+
// One entry per account, however many links for it the page has (say,
|
|
444
|
+
// one in a toolbar and one in the Subscribe menu): they all agree.
|
|
445
|
+
type FollowState = {
|
|
446
|
+
following: boolean;
|
|
447
|
+
buttons: HTMLButtonElement[];
|
|
448
|
+
renders: (() => void)[];
|
|
449
|
+
set: (on: boolean) => void;
|
|
450
|
+
press: () => Promise<void>;
|
|
451
|
+
};
|
|
452
|
+
const followButtons = new Map<string, FollowState>();
|
|
411
453
|
// Following isn't part of signing in: the first follow asks the
|
|
412
454
|
// reader's account host for it, then carries on when they're back.
|
|
413
455
|
const askFollowPermission = (subject: string, who: string) =>
|
|
@@ -415,19 +457,47 @@ const { base } = settings;
|
|
|
415
457
|
const setUpFollow = async () => {
|
|
416
458
|
for (const link of document.querySelectorAll<HTMLElement>("[data-atmosphere-follow]")) {
|
|
417
459
|
const subject = link.dataset.atmosphereFollow!;
|
|
418
|
-
if (
|
|
460
|
+
if (link.nextElementSibling?.matches(".atmo-follow")) continue;
|
|
419
461
|
// Nobody can follow themselves: the account's owner keeps the link.
|
|
420
462
|
if (subject === me.did) continue;
|
|
421
463
|
const who = link.dataset.atmosphereFollowHandle ? `@${link.dataset.atmosphereFollowHandle}` : t("comment.someone");
|
|
464
|
+
let state = followButtons.get(subject);
|
|
465
|
+
const isNew = !state;
|
|
466
|
+
if (!state) {
|
|
467
|
+
const created: FollowState = {
|
|
468
|
+
following: false,
|
|
469
|
+
buttons: [],
|
|
470
|
+
renders: [],
|
|
471
|
+
set: (on) => {
|
|
472
|
+
created.following = on;
|
|
473
|
+
created.renders.forEach((render) => render());
|
|
474
|
+
},
|
|
475
|
+
press: async () => {
|
|
476
|
+
created.buttons.forEach((each) => (each.disabled = true));
|
|
477
|
+
try {
|
|
478
|
+
const response = await act("follow", { subject, on: !created.following });
|
|
479
|
+
const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
|
|
480
|
+
if (response.ok) return created.set(!!data.following);
|
|
481
|
+
if (data.error === "needs-permission") return void askFollowPermission(subject, who);
|
|
482
|
+
if (data.error === "signed-out") location.reload();
|
|
483
|
+
} finally {
|
|
484
|
+
created.buttons.forEach((each) => (each.disabled = false));
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
state = created;
|
|
489
|
+
followButtons.set(subject, state);
|
|
490
|
+
}
|
|
422
491
|
const button = make("button", "atmo-follow");
|
|
423
492
|
button.type = "button";
|
|
424
|
-
let isFollowing = false;
|
|
425
493
|
// The account's avatar, through the site (its own accounts' are always served).
|
|
426
494
|
const avatar = make("img", "atmo-follow__avatar");
|
|
427
495
|
avatar.src = `${base}/media?avatar=${encodeURIComponent(subject)}`;
|
|
428
496
|
avatar.alt = "";
|
|
429
497
|
avatar.onerror = () => avatar.remove();
|
|
498
|
+
const current = state;
|
|
430
499
|
const render = () => {
|
|
500
|
+
const isFollowing = current.following;
|
|
431
501
|
button.setAttribute("aria-pressed", String(isFollowing));
|
|
432
502
|
// The butterfly, then "Follow" with the account's avatar beside its
|
|
433
503
|
// handle (wherever the language puts the handle), or the avatar
|
|
@@ -444,32 +514,19 @@ const { base } = settings;
|
|
|
444
514
|
button.title = isFollowing ? t("follow.followingLabel", { handle: who }) : t("follow.followAs", { handle: who, me: handle });
|
|
445
515
|
button.setAttribute("aria-label", button.title);
|
|
446
516
|
};
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
};
|
|
451
|
-
const press = async () => {
|
|
452
|
-
button.disabled = true;
|
|
453
|
-
try {
|
|
454
|
-
const response = await act("follow", { subject, on: !isFollowing });
|
|
455
|
-
const data = (await response.json().catch(() => ({}))) as { following?: boolean; error?: string };
|
|
456
|
-
if (response.ok) return set(!!data.following);
|
|
457
|
-
if (data.error === "needs-permission") return void askFollowPermission(subject, who);
|
|
458
|
-
if (data.error === "signed-out") location.reload();
|
|
459
|
-
} finally {
|
|
460
|
-
button.disabled = false;
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
button.addEventListener("click", () => void press());
|
|
464
|
-
followButtons.set(subject, { get following() { return isFollowing; }, set, press });
|
|
517
|
+
button.addEventListener("click", () => void current.press());
|
|
518
|
+
current.buttons.push(button);
|
|
519
|
+
current.renders.push(render);
|
|
465
520
|
render();
|
|
466
521
|
// The button takes the link's place. (Inline, as a site's own style
|
|
467
522
|
// for the link could otherwise keep it showing.)
|
|
468
523
|
link.hidden = true;
|
|
469
524
|
link.style.display = "none";
|
|
470
525
|
link.after(button);
|
|
471
|
-
|
|
472
|
-
|
|
526
|
+
if (isNew) {
|
|
527
|
+
const known = await following(subject);
|
|
528
|
+
if (known !== null) current.set(known);
|
|
529
|
+
}
|
|
473
530
|
}
|
|
474
531
|
};
|
|
475
532
|
|
|
@@ -516,7 +573,11 @@ const { base } = settings;
|
|
|
516
573
|
return;
|
|
517
574
|
}
|
|
518
575
|
const button = post.querySelector<HTMLButtonElement>(`.atmo-post-action[data-kind='${kind}']`);
|
|
519
|
-
if (button && button.getAttribute("aria-pressed") !== "true")
|
|
576
|
+
if (button && button.getAttribute("aria-pressed") !== "true") {
|
|
577
|
+
await react(post, button, kind as Kind, true);
|
|
578
|
+
// A brief "Liked" or "Reposted", so it's clear it went through.
|
|
579
|
+
if (button.getAttribute("aria-pressed") === "true") showDone(button, t(kind === "like" ? "actions.liked" : "actions.reposted"));
|
|
580
|
+
}
|
|
520
581
|
};
|
|
521
582
|
|
|
522
583
|
// Back from signing in or allowing posting: open the Reply or Quote box they pressed.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Subscribe to the site's Standard.site publication, with a ⌄ menu of more
|
|
3
|
+
// ways to follow it (Copy link, the feed, Bluesky, Standard Reader, Leaflet
|
|
4
|
+
// and any links of the site's own). Shown only with the integration's
|
|
5
|
+
// `standardSite` option, and only with JavaScript (the menu needs it).
|
|
6
|
+
//
|
|
7
|
+
// Placed anywhere, such as a blog's list of posts. The Subscribe button
|
|
8
|
+
// itself is worked by AtmosphereReactions, which offers signing in, so put
|
|
9
|
+
// that in the layout too. AtmosphereComments uses this component for the one
|
|
10
|
+
// under each post, and works that button itself.
|
|
11
|
+
//
|
|
12
|
+
// Props:
|
|
13
|
+
// align which edge the menu lines up with: "start" (default) or "end"
|
|
14
|
+
// (for a button near the right-hand edge, so the menu opens leftwards)
|
|
15
|
+
// copyUrl what Copy link copies (default: the site's address)
|
|
16
|
+
// links more menu items, after the built-in ones: [{ label, href }]
|
|
17
|
+
// bluesky adds "View on Bluesky", going here (under a post: its thread)
|
|
18
|
+
//
|
|
19
|
+
// The menu's note ends by suggesting following the publication's account on
|
|
20
|
+
// Bluesky, with AtmosphereFollow: a link to its profile, which AtmosphereReactions
|
|
21
|
+
// turns into a Follow / Following button for signed-in readers.
|
|
22
|
+
// locale, messages as for AtmosphereComments
|
|
23
|
+
import logo from "../assets/union.svg?url";
|
|
24
|
+
import settings from "../lib/settings";
|
|
25
|
+
import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
|
|
26
|
+
import { escapeHtml } from "../lib/translate";
|
|
27
|
+
import { profileFor } from "../lib/profile";
|
|
28
|
+
import AtmosphereFollow from "./AtmosphereFollow.astro";
|
|
29
|
+
import "./atmosphere.css";
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
align?: "start" | "end";
|
|
33
|
+
copyUrl?: string;
|
|
34
|
+
links?: { label: string; href: string }[];
|
|
35
|
+
bluesky?: string;
|
|
36
|
+
locale?: string;
|
|
37
|
+
messages?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { align = "start", copyUrl = settings.site, links = [] } = Astro.props;
|
|
41
|
+
const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
|
|
42
|
+
const messages = messagesFor(lang, { ...settings.messages, ...Astro.props.messages });
|
|
43
|
+
const t = (key: string) => translate(messages, lang, key);
|
|
44
|
+
const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
|
|
45
|
+
const link = (href: string, text: string) => `<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(text)}</a>`;
|
|
46
|
+
|
|
47
|
+
const publication = settings.standardSite?.publication ?? "";
|
|
48
|
+
const feed = settings.standardSite?.feed;
|
|
49
|
+
// at://<did>/site.standard.publication/<rkey>
|
|
50
|
+
const [, , did = "", , rkey = ""] = publication.split("/");
|
|
51
|
+
const bluesky = Astro.props.bluesky ?? "";
|
|
52
|
+
// The publication's account, by handle, for the note and the Follow link
|
|
53
|
+
// (looked up when the page is built; without it, the note leaves that part out).
|
|
54
|
+
const account = did ? await profileFor(did).catch(() => null) : null;
|
|
55
|
+
const accountHandle = account && account.handle !== did ? account.handle : "";
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
{publication && (
|
|
59
|
+
<atmosphere-subscribe class:list={["atmo-subscribe", align === "end" && "atmo-subscribe--end"]} data-subscribe-panel data-pagefind-ignore hidden>
|
|
60
|
+
<button type="button" class="atmo__action atmo-subscribe__button" data-atmosphere-subscribe hidden>
|
|
61
|
+
<span class="atmo-subscribe__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
62
|
+
<span data-atmosphere-subscribe-label>{t("subscribe.subscribe")}</span>
|
|
63
|
+
</button>
|
|
64
|
+
<button type="button" class="atmo__action atmo-subscribe__more" aria-label={t("subscribe.more")} title={t("subscribe.more")} aria-expanded="false" data-subscribe-more>
|
|
65
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
66
|
+
</button>
|
|
67
|
+
<div class="atmo-subscribe__menu" data-subscribe-menu hidden>
|
|
68
|
+
<div class="atmo-subscribe__note">
|
|
69
|
+
<p
|
|
70
|
+
set:html={th(feed ? "subscribe.panelTextFeed" : "subscribe.panelText", {
|
|
71
|
+
leaflet: link("https://leaflet.pub", "Leaflet"),
|
|
72
|
+
standardReader: link("https://standard-reader.app", "Standard Reader"),
|
|
73
|
+
}) + (accountHandle ? ` ${escapeHtml(translate(messages, lang, "subscribe.followNote", { handle: `@${accountHandle}` }))}` : "")}
|
|
74
|
+
/>
|
|
75
|
+
{accountHandle && <AtmosphereFollow account={did} label={t("subscribe.follow")} class="atmo-subscribe__follow" locale={lang} messages={messages} />}
|
|
76
|
+
</div>
|
|
77
|
+
<button type="button" data-subscribe-copy={copyUrl} data-copied={t("subscribe.copied")}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" /></svg><span data-copy-label>{t("subscribe.copyLink")}</span></button>
|
|
78
|
+
{feed && <a href={feed}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 11a8 8 0 0 1 8 8M5 5a14 14 0 0 1 14 14" /><circle cx="6" cy="18" r="1" /></svg><span>{t("subscribe.feed")}</span></a>}
|
|
79
|
+
<a href={bluesky || undefined} hidden={!bluesky} data-subscribe-bluesky target="_blank" rel="noopener"><svg class="atmo-subscribe__filled" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" /></svg><span>{t("subscribe.viewBluesky")}</span></a>
|
|
80
|
+
<a href={`https://standard-reader.app/p/${did}/${rkey}`} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{t("subscribe.standardReader")}</span></a>
|
|
81
|
+
<a href={`https://leaflet.pub/lish/${did}/${rkey}`} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{t("subscribe.leaflet")}</span></a>
|
|
82
|
+
{links.map(({ label, href }) => (
|
|
83
|
+
<a href={href} target="_blank" rel="noopener"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 5h5v5M19 5l-8 8M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4" /></svg><span>{label}</span></a>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</atmosphere-subscribe>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<script>
|
|
90
|
+
// The ⌄ menu: opens and closes (click, a click elsewhere, Escape) and Copy
|
|
91
|
+
// link. Shows itself once this runs, except under a post, where
|
|
92
|
+
// AtmosphereComments shows it when the comments are ready.
|
|
93
|
+
class AtmosphereSubscribe extends HTMLElement {
|
|
94
|
+
connectedCallback() {
|
|
95
|
+
const more = this.querySelector<HTMLButtonElement>("[data-subscribe-more]");
|
|
96
|
+
const menu = this.querySelector<HTMLElement>("[data-subscribe-menu]");
|
|
97
|
+
if (!more || !menu || this.dataset.ready !== undefined) return;
|
|
98
|
+
this.dataset.ready = "";
|
|
99
|
+
if (!this.closest("atmosphere-comments")) this.hidden = false;
|
|
100
|
+
const setMenu = (open: boolean) => {
|
|
101
|
+
menu.hidden = !open;
|
|
102
|
+
more.setAttribute("aria-expanded", String(open));
|
|
103
|
+
};
|
|
104
|
+
more.addEventListener("click", () => {
|
|
105
|
+
setMenu(menu.hidden);
|
|
106
|
+
if (!menu.hidden) menu.querySelector<HTMLElement>("[data-subscribe-copy]")?.focus();
|
|
107
|
+
});
|
|
108
|
+
document.addEventListener("click", (event) => {
|
|
109
|
+
if (!this.contains(event.target as Node)) setMenu(false);
|
|
110
|
+
});
|
|
111
|
+
this.addEventListener("keydown", (event) => {
|
|
112
|
+
if (event.key === "Escape" && !menu.hidden) {
|
|
113
|
+
setMenu(false);
|
|
114
|
+
more.focus();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const copy = menu.querySelector<HTMLButtonElement>("[data-subscribe-copy]")!;
|
|
118
|
+
const copyText = copy.querySelector<HTMLElement>("[data-copy-label]")!;
|
|
119
|
+
const copyLabel = copyText.textContent;
|
|
120
|
+
copy.addEventListener("click", async () => {
|
|
121
|
+
try {
|
|
122
|
+
await navigator.clipboard.writeText(copy.dataset.subscribeCopy!);
|
|
123
|
+
copyText.textContent = copy.dataset.copied!;
|
|
124
|
+
window.setTimeout(() => {
|
|
125
|
+
copyText.textContent = copyLabel;
|
|
126
|
+
setMenu(false);
|
|
127
|
+
}, 1200);
|
|
128
|
+
} catch {
|
|
129
|
+
// Clipboard refused: the button stays as it was.
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!customElements.get("atmosphere-subscribe")) customElements.define("atmosphere-subscribe", AtmosphereSubscribe);
|
|
135
|
+
</script>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// cancel show a Cancel button that closes an overlay (default false)
|
|
13
13
|
import { translate, translateHtml } from "../lib/i18n";
|
|
14
14
|
import { escapeHtml } from "../lib/translate";
|
|
15
|
+
import PostPreview from "./PostPreview.astro";
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
id: string;
|
|
@@ -31,6 +32,10 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
|
|
|
31
32
|
<span data-context-text></span>
|
|
32
33
|
<button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
|
|
33
34
|
</p>
|
|
35
|
+
<!-- The post being replied to, when it won't be on this page (AtmosphereReactions). -->
|
|
36
|
+
<div class="atmo__quoted atmo__parent" data-parent hidden>
|
|
37
|
+
<PostPreview />
|
|
38
|
+
</div>
|
|
34
39
|
<label for={`${id}-text`} class="atmo__as">
|
|
35
40
|
<span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
|
|
36
41
|
<span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
|
|
@@ -53,23 +58,8 @@ const th = (key: string, html: Record<string, string>) => translateHtml(messages
|
|
|
53
58
|
<div class="atmo__preview-body">
|
|
54
59
|
<p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
|
|
55
60
|
<p class="atmo__preview-text" data-preview-text></p>
|
|
56
|
-
<div class="atmo__quoted">
|
|
57
|
-
<
|
|
58
|
-
<span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
|
|
59
|
-
<strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
|
|
60
|
-
</p>
|
|
61
|
-
<p class="atmo__preview-text" data-quoted-text></p>
|
|
62
|
-
<div class="atmo__card" data-card hidden>
|
|
63
|
-
<img data-card-image alt="" hidden />
|
|
64
|
-
<div>
|
|
65
|
-
<strong data-card-title></strong>
|
|
66
|
-
<span data-card-description></span>
|
|
67
|
-
<span class="atmo__card-domain">
|
|
68
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
|
|
69
|
-
<span data-card-domain></span>
|
|
70
|
-
</span>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
61
|
+
<div class="atmo__quoted" data-quoted-post>
|
|
62
|
+
<PostPreview />
|
|
73
63
|
</div>
|
|
74
64
|
</div>
|
|
75
65
|
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A Bluesky post as the composer shows it: author (avatar, name, handle,
|
|
3
|
+
// age), its words and its link card. Empty here, filled in by showPost() in
|
|
4
|
+
// lib/client/composer.ts. Used inside the Quote preview (the post being
|
|
5
|
+
// quoted) and above the box when replying from AtmosphereReactions (the post
|
|
6
|
+
// being replied to); not meant to be used on its own.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<p class="atmo__preview-author">
|
|
10
|
+
<span class="atmo__avatar atmo__avatar--small" data-quoted-avatar aria-hidden="true"></span>
|
|
11
|
+
<strong data-quoted-name></strong> <span data-quoted-handle></span> <span data-quoted-age></span>
|
|
12
|
+
</p>
|
|
13
|
+
<p class="atmo__preview-text" data-quoted-text></p>
|
|
14
|
+
<div class="atmo__card" data-card hidden>
|
|
15
|
+
<img data-card-image alt="" hidden />
|
|
16
|
+
<div>
|
|
17
|
+
<strong data-card-title></strong>
|
|
18
|
+
<span data-card-description></span>
|
|
19
|
+
<span class="atmo__card-domain">
|
|
20
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.5 2.5 3.8 5.5 3.8 9s-1.3 6.5-3.8 9c-2.5-2.5-3.8-5.5-3.8-9s1.3-6.5 3.8-9Z" /></svg>
|
|
21
|
+
<span data-card-domain></span>
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
@@ -30,7 +30,14 @@ interface Props {
|
|
|
30
30
|
|
|
31
31
|
const { id, returnTo, messages, lang, heading, permissions = [] } = Astro.props;
|
|
32
32
|
const { policyUri } = settings;
|
|
33
|
-
const t = (key: string) => translate(messages, lang, key);
|
|
33
|
+
const t = (key: string, vars?: Record<string, string>) => translate(messages, lang, key, vars);
|
|
34
|
+
// The site's name in "… can use it only for what you approved", e.g. saltedlolly.com.
|
|
35
|
+
let siteName = "";
|
|
36
|
+
try {
|
|
37
|
+
siteName = new URL(settings.site).hostname;
|
|
38
|
+
} catch {
|
|
39
|
+
siteName = "";
|
|
40
|
+
}
|
|
34
41
|
const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
|
|
35
42
|
// Links lead off the page, so they open in a new tab.
|
|
36
43
|
const link = (href: string, text: string) =>
|
|
@@ -40,12 +47,13 @@ const link = (href: string, text: string) =>
|
|
|
40
47
|
<form class="atmo__login" id={`${id}-login`} data-login-form>
|
|
41
48
|
<label for={`${id}-handle`}>{heading ?? t("signIn.heading")}</label>
|
|
42
49
|
<p
|
|
43
|
-
class="atmo__hint"
|
|
50
|
+
class="atmo__hint atmo__hint--accounts"
|
|
44
51
|
set:html={th("signIn.anyAccount", {
|
|
45
52
|
atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
|
|
46
53
|
bluesky: link("https://bsky.app", "Bluesky"),
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
}) +
|
|
55
|
+
"<br>" +
|
|
56
|
+
th("signIn.noAccount", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
|
|
49
57
|
/>
|
|
50
58
|
<div class="atmo__row">
|
|
51
59
|
<span class="atmo__handle">
|
|
@@ -84,9 +92,8 @@ const link = (href: string, text: string) =>
|
|
|
84
92
|
{/* The detail, for anyone who wants it. */}
|
|
85
93
|
<details class="atmo__details">
|
|
86
94
|
<summary>{t("signIn.more")}</summary>
|
|
87
|
-
<p class="atmo__hint">{t("signIn.privacy")}</p>
|
|
88
95
|
<p class="atmo__hint">
|
|
89
|
-
{t("signIn.
|
|
96
|
+
{t("signIn.privacy", { site: siteName || t("signIn.thisSite") })}
|
|
90
97
|
{policyUri && <Fragment> <a href={policyUri} target="_blank" rel="noopener">{t("signIn.policy")}</a></Fragment>}
|
|
91
98
|
</p>
|
|
92
99
|
</details>
|
|
@@ -108,6 +115,11 @@ const link = (href: string, text: string) =>
|
|
|
108
115
|
font-weight: 700;
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
/* The two lines about accounts, above the box: a little more room. */
|
|
119
|
+
.atmo__hint.atmo__hint--accounts {
|
|
120
|
+
line-height: 1.7;
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
.atmo__details summary {
|
|
112
124
|
width: fit-content;
|
|
113
125
|
color: var(--atmo-muted, inherit);
|
|
@@ -123,6 +135,8 @@ const link = (href: string, text: string) =>
|
|
|
123
135
|
margin: 0;
|
|
124
136
|
color: var(--atmo-muted, inherit);
|
|
125
137
|
font-size: 0.9em;
|
|
138
|
+
/* Tighter than a site's body text, which is often set for long reading. */
|
|
139
|
+
line-height: 1.5;
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
.atmo__row {
|
|
@@ -276,6 +276,12 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
|
|
|
276
276
|
box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/* <AtmosphereSubscribe align="end">: near the right-hand edge, the menu opens leftwards. */
|
|
280
|
+
.atmo-subscribe--end .atmo-subscribe__menu {
|
|
281
|
+
right: 0;
|
|
282
|
+
left: auto;
|
|
283
|
+
}
|
|
284
|
+
|
|
279
285
|
.atmo-subscribe__menu[hidden] {
|
|
280
286
|
display: none;
|
|
281
287
|
}
|
|
@@ -286,6 +292,23 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
|
|
|
286
292
|
border-bottom: 1px solid var(--atmo-border, #8886);
|
|
287
293
|
color: var(--atmo-muted, inherit);
|
|
288
294
|
font-size: 0.85em;
|
|
295
|
+
line-height: 1.5;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.atmo-subscribe__note p {
|
|
299
|
+
margin: 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* "Follow on Bluesky →" under the note, which AtmosphereReactions turns into
|
|
303
|
+
a Follow / Following button for signed-in readers. */
|
|
304
|
+
.atmo-subscribe__follow {
|
|
305
|
+
display: inline-block;
|
|
306
|
+
margin-top: 8px;
|
|
307
|
+
font-weight: 700;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.atmo-subscribe__note .atmo-follow {
|
|
311
|
+
margin-top: 8px;
|
|
289
312
|
}
|
|
290
313
|
|
|
291
314
|
.atmo-subscribe__menu > a[hidden] {
|
|
@@ -562,6 +585,7 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
|
|
|
562
585
|
}
|
|
563
586
|
|
|
564
587
|
.atmo__preview-post p,
|
|
588
|
+
.atmo__quoted p,
|
|
565
589
|
.atmo__card span {
|
|
566
590
|
margin: 0;
|
|
567
591
|
}
|
|
@@ -868,10 +892,148 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
|
|
|
868
892
|
margin: 0;
|
|
869
893
|
}
|
|
870
894
|
|
|
871
|
-
/* In place of a comment whose author asks not to be shown to logged-out people
|
|
872
|
-
|
|
873
|
-
|
|
895
|
+
/* In place of a comment whose author asks not to be shown to logged-out people:
|
|
896
|
+
a blurred, made-up comment (grey bars, never the real one) under an explanation. */
|
|
897
|
+
:where(.atmo-comment--withheld article) {
|
|
898
|
+
position: relative;
|
|
899
|
+
overflow: hidden;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
.atmo-withheld__ghost {
|
|
903
|
+
filter: blur(5px);
|
|
904
|
+
opacity: 0.7;
|
|
905
|
+
pointer-events: none;
|
|
906
|
+
user-select: none;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
.atmo-withheld__names,
|
|
910
|
+
.atmo-withheld__text {
|
|
911
|
+
display: grid;
|
|
912
|
+
gap: 8px;
|
|
913
|
+
align-content: center;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.atmo-withheld__text {
|
|
917
|
+
margin-top: 14px;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.atmo-withheld__bar {
|
|
921
|
+
display: block;
|
|
922
|
+
max-width: 100%;
|
|
923
|
+
height: 0.75em;
|
|
924
|
+
border-radius: 999px;
|
|
925
|
+
background: color-mix(in srgb, var(--atmo-muted, currentColor) 45%, transparent);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
.atmo-withheld__cover {
|
|
929
|
+
position: absolute;
|
|
930
|
+
inset: 0;
|
|
931
|
+
display: grid;
|
|
932
|
+
place-content: center;
|
|
933
|
+
gap: 4px;
|
|
934
|
+
padding: 12px 20px;
|
|
935
|
+
border: 0;
|
|
936
|
+
color: var(--atmo-text, inherit);
|
|
937
|
+
font: inherit;
|
|
938
|
+
text-align: center;
|
|
939
|
+
background: color-mix(in srgb, var(--atmo-card-bg, Canvas) 35%, transparent);
|
|
940
|
+
cursor: pointer;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.atmo-withheld__cover:disabled {
|
|
944
|
+
cursor: default;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
.atmo-withheld__cover span {
|
|
948
|
+
max-width: 42ch;
|
|
874
949
|
color: var(--atmo-muted, inherit);
|
|
875
950
|
font-size: 0.9em;
|
|
876
|
-
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.atmo-withheld__cover:not(:disabled):hover strong,
|
|
954
|
+
.atmo-withheld__cover:not(:disabled):focus-visible strong {
|
|
955
|
+
text-decoration: underline;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
.atmo-withheld__cover:focus-visible {
|
|
959
|
+
outline: 2px solid var(--atmo-accent, #1185fe);
|
|
960
|
+
outline-offset: -2px;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/* A faceless avatar: a head and shoulders. */
|
|
964
|
+
:where(.atmo-avatar-anonymous) {
|
|
965
|
+
display: inline-block;
|
|
966
|
+
background:
|
|
967
|
+
radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 18%, transparent 19%),
|
|
968
|
+
radial-gradient(ellipse 34% 26% at 50% 88%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 99%, transparent 100%),
|
|
969
|
+
var(--atmo-field-bg, #8882);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/* "Liked" / "Reposted" over a button, when something pressed before signing
|
|
973
|
+
in is finished once the reader is back (lib/client/done.ts): the button's
|
|
974
|
+
icon, enlarged, pops in above it with the word, then floats up and fades. */
|
|
975
|
+
.atmo-has-done {
|
|
976
|
+
position: relative;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.atmo-done {
|
|
980
|
+
position: absolute;
|
|
981
|
+
z-index: 30;
|
|
982
|
+
bottom: calc(100% + 6px);
|
|
983
|
+
left: 50%;
|
|
984
|
+
display: grid;
|
|
985
|
+
justify-items: center;
|
|
986
|
+
gap: 2px;
|
|
987
|
+
padding: 8px 14px 6px;
|
|
988
|
+
border: 1px solid var(--atmo-border, #8886);
|
|
989
|
+
border-radius: var(--atmo-radius, 10px);
|
|
990
|
+
background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
|
|
991
|
+
box-shadow: 0 10px 28px rgb(0 0 0 / 0.25);
|
|
992
|
+
color: var(--atmo-accent, #1185fe);
|
|
993
|
+
font-size: 0.85rem;
|
|
994
|
+
font-weight: 700;
|
|
995
|
+
line-height: 1.2;
|
|
996
|
+
white-space: nowrap;
|
|
997
|
+
pointer-events: none;
|
|
998
|
+
transform: translateX(-50%);
|
|
999
|
+
animation: atmo-done 1.8s ease-out forwards;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/* Specific enough to beat a button's own icon size; its colours still apply. */
|
|
1003
|
+
.atmo-has-done .atmo-done svg.atmo-done__icon {
|
|
1004
|
+
width: 34px;
|
|
1005
|
+
height: 34px;
|
|
1006
|
+
animation: atmo-done-pop 0.5s cubic-bezier(0.3, 1.6, 0.5, 1) both;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.atmo-done__label {
|
|
1010
|
+
color: var(--atmo-text, CanvasText);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
@keyframes atmo-done {
|
|
1014
|
+
0% { opacity: 0; transform: translate(-50%, 6px); }
|
|
1015
|
+
12% { opacity: 1; transform: translate(-50%, 0); }
|
|
1016
|
+
75% { opacity: 1; transform: translate(-50%, 0); }
|
|
1017
|
+
100% { opacity: 0; transform: translate(-50%, -10px); }
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
@keyframes atmo-done-pop {
|
|
1021
|
+
0% { transform: scale(0.3); }
|
|
1022
|
+
60% { transform: scale(1.25); }
|
|
1023
|
+
100% { transform: scale(1); }
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1027
|
+
.atmo-done {
|
|
1028
|
+
animation-name: atmo-done-fade;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.atmo-has-done .atmo-done svg.atmo-done__icon {
|
|
1032
|
+
animation: none;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
@keyframes atmo-done-fade {
|
|
1036
|
+
0%, 100% { opacity: 0; }
|
|
1037
|
+
12%, 75% { opacity: 1; }
|
|
1038
|
+
}
|
|
877
1039
|
}
|