astro-atmosphere-comments 0.7.0 → 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 +12 -0
- package/README.md +35 -5
- package/components/AtmosphereComments.astro +19 -65
- 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 +93 -0
- package/lib/client/composer.ts +52 -35
- package/lib/client/done.ts +54 -0
- package/lib/resolve.ts +2 -1
- package/locales/de.json +13 -6
- package/locales/en.json +12 -6
- package/locales/es.json +13 -6
- package/locales/fr.json +13 -6
- package/locales/it.json +12 -5
- package/locales/ja.json +13 -6
- package/locales/nl.json +12 -5
- package/locales/pt.json +13 -6
- package/package.json +4 -2
|
@@ -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
|
}
|
|
@@ -944,3 +968,72 @@ dialog.atmo.atmo-signin:has(> .atmo__login) {
|
|
|
944
968
|
radial-gradient(ellipse 34% 26% at 50% 88%, color-mix(in srgb, var(--atmo-muted, #888) 70%, transparent) 0 99%, transparent 100%),
|
|
945
969
|
var(--atmo-field-bg, #8882);
|
|
946
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
|
+
}
|
|
1039
|
+
}
|
package/lib/client/composer.ts
CHANGED
|
@@ -190,14 +190,59 @@ function postAge(when: string): string {
|
|
|
190
190
|
return date.toLocaleDateString(lang, { month: "short", day: "numeric", ...(!thisYear && { year: "numeric" }) });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** A post as the site's `post` route gives it (routes/post.ts). */
|
|
194
|
+
export type ShownPost = {
|
|
195
|
+
author: { handle: string; displayName?: string; avatar?: string };
|
|
196
|
+
text: string;
|
|
197
|
+
indexedAt?: string;
|
|
198
|
+
external?: { uri: string; title: string; description: string; thumb?: string };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Fetches one of the site's posts through the site, or null if it can't. */
|
|
202
|
+
export async function fetchPost(base: string, uri: string): Promise<ShownPost | null> {
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(`${base}/post?uri=${encodeURIComponent(uri)}`);
|
|
205
|
+
return response.ok ? ((await response.json()) as ShownPost) : null;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Fills a PostPreview (components/PostPreview.astro) inside `container` with `post`. */
|
|
212
|
+
export function showPost(container: HTMLElement, post: ShownPost, ctx: ComposerContext) {
|
|
213
|
+
const $ = <T extends Element>(selector: string) => container.querySelector<T>(selector)!;
|
|
214
|
+
const name = post.author.displayName || post.author.handle;
|
|
215
|
+
$<HTMLElement>("[data-quoted-name]").textContent = name;
|
|
216
|
+
ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), name, post.author.avatar);
|
|
217
|
+
$<HTMLElement>("[data-quoted-handle]").textContent = `@${post.author.handle}`;
|
|
218
|
+
$<HTMLElement>("[data-quoted-age]").textContent = post.indexedAt ? `· ${postAge(post.indexedAt)}` : "";
|
|
219
|
+
$<HTMLElement>("[data-quoted-text]").textContent = post.text;
|
|
220
|
+
$<HTMLElement>("[data-quoted-text]").hidden = !post.text;
|
|
221
|
+
const card = $<HTMLElement>("[data-card]");
|
|
222
|
+
card.hidden = !post.external;
|
|
223
|
+
if (post.external) {
|
|
224
|
+
$<HTMLElement>("[data-card-title]").textContent = post.external.title;
|
|
225
|
+
$<HTMLElement>("[data-card-description]").textContent = post.external.description;
|
|
226
|
+
$<HTMLElement>("[data-card-domain]").textContent = new URL(post.external.uri).hostname;
|
|
227
|
+
const img = $<HTMLImageElement>("[data-card-image]");
|
|
228
|
+
img.hidden = true;
|
|
229
|
+
img.removeAttribute("src");
|
|
230
|
+
if (post.external.thumb) {
|
|
231
|
+
img.onload = () => (img.hidden = false);
|
|
232
|
+
img.onerror = () => (img.hidden = true);
|
|
233
|
+
img.src = post.external.thumb;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
/**
|
|
194
239
|
* The quote preview inside a box: the reader's words as they type, with the
|
|
195
240
|
* post being quoted beneath, as Bluesky will show it. Call update() as they
|
|
196
241
|
* type and load(uri) when quoting starts.
|
|
197
242
|
*/
|
|
198
243
|
export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElement, ctx: ComposerContext) {
|
|
199
|
-
const
|
|
200
|
-
const
|
|
244
|
+
const previewText = form.querySelector<HTMLElement>("[data-preview-text]")!;
|
|
245
|
+
const quoted = form.querySelector<HTMLElement>("[data-quoted-post]")!;
|
|
201
246
|
let loaded = "";
|
|
202
247
|
const update = () => {
|
|
203
248
|
const words = textarea.value.trim();
|
|
@@ -207,39 +252,11 @@ export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElemen
|
|
|
207
252
|
const load = async (uri: string) => {
|
|
208
253
|
update();
|
|
209
254
|
if (loaded === uri) return;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
text: string;
|
|
216
|
-
indexedAt?: string;
|
|
217
|
-
external?: { uri: string; title: string; description: string; thumb?: string };
|
|
218
|
-
};
|
|
219
|
-
loaded = uri;
|
|
220
|
-
const quotedName = quoted.author.displayName || quoted.author.handle;
|
|
221
|
-
$<HTMLElement>("[data-quoted-name]").textContent = quotedName;
|
|
222
|
-
ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
|
|
223
|
-
$<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
|
|
224
|
-
$<HTMLElement>("[data-quoted-age]").textContent = quoted.indexedAt ? `· ${postAge(quoted.indexedAt)}` : "";
|
|
225
|
-
$<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
|
|
226
|
-
const card = $<HTMLElement>("[data-card]");
|
|
227
|
-
card.hidden = !quoted.external;
|
|
228
|
-
if (quoted.external) {
|
|
229
|
-
$<HTMLElement>("[data-card-title]").textContent = quoted.external.title;
|
|
230
|
-
$<HTMLElement>("[data-card-description]").textContent = quoted.external.description;
|
|
231
|
-
$<HTMLElement>("[data-card-domain]").textContent = new URL(quoted.external.uri).hostname;
|
|
232
|
-
const img = $<HTMLImageElement>("[data-card-image]");
|
|
233
|
-
img.hidden = true;
|
|
234
|
-
if (quoted.external.thumb) {
|
|
235
|
-
img.onload = () => (img.hidden = false);
|
|
236
|
-
img.onerror = () => (img.hidden = true);
|
|
237
|
-
img.src = quoted.external.thumb;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
} catch {
|
|
241
|
-
// The preview is a nicety: quoting still works without it.
|
|
242
|
-
}
|
|
255
|
+
// The preview is a nicety: quoting still works without it.
|
|
256
|
+
const post = await fetchPost(ctx.base, uri);
|
|
257
|
+
if (!post) return;
|
|
258
|
+
loaded = uri;
|
|
259
|
+
showPost(quoted, post, ctx);
|
|
243
260
|
};
|
|
244
261
|
return { update, load };
|
|
245
262
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A brief "Liked" or "Reposted" over a button, with its icon enlarged and
|
|
2
|
+
// popping, when something the reader pressed before signing in is finished
|
|
3
|
+
// once they're back: otherwise the only sign is a small change of colour,
|
|
4
|
+
// easy to miss after a trip to their account host. Styles: .atmo-done in
|
|
5
|
+
// atmosphere.css (with a plain fade for readers who ask for less motion).
|
|
6
|
+
|
|
7
|
+
/** Shows the bubble over `button` now. */
|
|
8
|
+
export function showDone(button: HTMLElement, label: string) {
|
|
9
|
+
button.querySelector(":scope > .atmo-done")?.remove();
|
|
10
|
+
const bubble = document.createElement("span");
|
|
11
|
+
bubble.className = "atmo-done";
|
|
12
|
+
// Screen readers already hear the component's own status line.
|
|
13
|
+
bubble.setAttribute("aria-hidden", "true");
|
|
14
|
+
const icon = button.querySelector("svg")?.cloneNode(true) as SVGElement | undefined;
|
|
15
|
+
if (icon) {
|
|
16
|
+
icon.removeAttribute("class");
|
|
17
|
+
icon.classList.add("atmo-done__icon");
|
|
18
|
+
bubble.append(icon);
|
|
19
|
+
}
|
|
20
|
+
bubble.append(Object.assign(document.createElement("span"), { className: "atmo-done__label", textContent: label }));
|
|
21
|
+
button.classList.add("atmo-has-done");
|
|
22
|
+
button.append(bubble);
|
|
23
|
+
const finish = () => {
|
|
24
|
+
bubble.remove();
|
|
25
|
+
if (!button.querySelector(":scope > .atmo-done")) button.classList.remove("atmo-has-done");
|
|
26
|
+
};
|
|
27
|
+
bubble.addEventListener("animationend", (event) => {
|
|
28
|
+
if (event.target === bubble) finish();
|
|
29
|
+
});
|
|
30
|
+
// In case animations don't run (a hidden tab, say).
|
|
31
|
+
window.setTimeout(finish, 3000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shows the bubble after `button` is pressed, once its request has finished
|
|
36
|
+
* (the component marks it `data-busy` meanwhile) and only if it's still
|
|
37
|
+
* pressed (aria-pressed="true"), so it appears only when the like or repost
|
|
38
|
+
* actually went through. Call it just before pressing. Gives up after
|
|
39
|
+
* `timeout` milliseconds.
|
|
40
|
+
*/
|
|
41
|
+
export function showDoneWhenPressed(button: HTMLElement, label: string, timeout = 20000) {
|
|
42
|
+
let started = false;
|
|
43
|
+
const observer = new MutationObserver(() => {
|
|
44
|
+
if ("busy" in button.dataset) {
|
|
45
|
+
started = true;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!started) return;
|
|
49
|
+
observer.disconnect();
|
|
50
|
+
if (button.getAttribute("aria-pressed") === "true") showDone(button, label);
|
|
51
|
+
});
|
|
52
|
+
observer.observe(button, { attributes: true, attributeFilter: ["data-busy", "aria-pressed"] });
|
|
53
|
+
window.setTimeout(() => observer.disconnect(), timeout);
|
|
54
|
+
}
|
package/lib/resolve.ts
CHANGED
|
@@ -12,7 +12,8 @@ async function getJson<T>(url: string): Promise<T> {
|
|
|
12
12
|
return (await response.json()) as T;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** A handle → its DID, through Bluesky's public service. */
|
|
16
|
+
export async function resolveHandle(handle: string): Promise<string> {
|
|
16
17
|
const { did } = await getJson<{ did: string }>(
|
|
17
18
|
`${APPVIEW}com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
|
|
18
19
|
);
|
package/locales/de.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"signIn.heading": "Melde dich an, um zu kommentieren, zu liken oder zu reposten",
|
|
3
|
-
"signIn.anyAccount": "
|
|
3
|
+
"signIn.anyAccount": "Nutze dein {{bluesky}}-Konto oder ein anderes {{atmosphereAccount}}.",
|
|
4
|
+
"signIn.noAccount": "Noch kein Konto? {{createAccount}}.",
|
|
4
5
|
"signIn.atmosphereAccount": "Atmosphere-Konto",
|
|
5
6
|
"signIn.button": "Mit Atmosphere anmelden",
|
|
6
7
|
"signIn.matchingAccounts": "Passende Konten",
|
|
7
|
-
"signIn.privacy": "Du
|
|
8
|
+
"signIn.privacy": "Du meldest dich auf der Seite deines eigenen Kontos an (für die meisten die von Bluesky). Deine Anmeldung bleibt in diesem Browser. {{site}} kann sie nur für das nutzen, was du erlaubt hast, und wenn du dich hier abmeldest, endet sie.",
|
|
9
|
+
"signIn.thisSite": "Diese Website",
|
|
8
10
|
"signIn.createAccount": "Erstelle ein kostenloses Atmosphere-Konto",
|
|
9
|
-
"elsewhere.text": "
|
|
10
|
-
"elsewhere.link": "
|
|
11
|
+
"elsewhere.text": "Lieber nicht hier anmelden? {{link}}",
|
|
12
|
+
"elsewhere.link": "Auf Bluesky reagieren →",
|
|
11
13
|
"actions.label": "Auf diesen Beitrag reagieren",
|
|
12
14
|
"actions.like": "Gefällt mir",
|
|
13
15
|
"actions.liked": "Gefällt dir",
|
|
@@ -90,9 +92,9 @@
|
|
|
90
92
|
"follow.following": "Folge ich",
|
|
91
93
|
"follow.followAs": "{{handle}} als {{me}} folgen",
|
|
92
94
|
"follow.followingLabel": "Du folgst {{handle}}",
|
|
95
|
+
"follow.link": "{{handle}} auf Bluesky folgen",
|
|
93
96
|
"credit": "Bereitgestellt von {{link}}",
|
|
94
97
|
"callback.signingIn": "Anmeldung läuft …",
|
|
95
|
-
"signIn.access": "Deine Anmeldung bleibt in diesem Browser: Der Server dieser Seite sieht sie nie und kann nicht für dich posten, und beim Abmelden wird sie aufgehoben.",
|
|
96
98
|
"signIn.policy": "Wie diese Seite mit deinen Daten umgeht",
|
|
97
99
|
"composer.needsPermission": "Um hier als {{handle}} zu kommentieren, braucht diese Seite eine weitere Erlaubnis von deinem Atmosphere-Konto.",
|
|
98
100
|
"comment.hide": "Ausblenden",
|
|
@@ -111,5 +113,10 @@
|
|
|
111
113
|
"comment.withheldTitle": "Kommentar ausgeblendet",
|
|
112
114
|
"comment.withheldSignedIn": "Er konnte gerade nicht geladen werden. Lade die Seite neu.",
|
|
113
115
|
"signInFirst.view": "Melde dich an, um ausgeblendete Kommentare zu sehen",
|
|
114
|
-
"reactions.hiddenPerson": "Jemand, der sein Konto nur angemeldeten Leuten zeigt"
|
|
116
|
+
"reactions.hiddenPerson": "Jemand, der sein Konto nur angemeldeten Leuten zeigt",
|
|
117
|
+
"subscribe.panelText": "Abonniere, um neue Blogbeiträge in Apps wie {{leaflet}} oder {{standardReader}} zu lesen.",
|
|
118
|
+
"subscribe.panelTextFeed": "Abonniere, um neue Blogbeiträge in Apps wie {{leaflet}} oder {{standardReader}} zu lesen, oder folge dem Feed in einem beliebigen Feedreader.",
|
|
119
|
+
"subscribe.followNote": "Um alles zu sehen, was {{handle}} postet, folge dem Konto auf Bluesky.",
|
|
120
|
+
"subscribe.follow": "Auf Bluesky folgen",
|
|
121
|
+
"subscribe.leaflet": "Auf Leaflet ansehen"
|
|
115
122
|
}
|
package/locales/en.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"signIn.heading": "Sign in to leave a comment",
|
|
3
|
-
"signIn.anyAccount": "Use {{bluesky}} or any other {{atmosphereAccount}}.
|
|
3
|
+
"signIn.anyAccount": "Use your {{bluesky}} account, or any other {{atmosphereAccount}}.",
|
|
4
|
+
"signIn.noAccount": "No account? {{createAccount}}.",
|
|
4
5
|
"signIn.atmosphereAccount": "Atmosphere account",
|
|
5
6
|
"signIn.more": "How does signing in work?",
|
|
6
7
|
"signIn.button": "Sign in with Atmosphere",
|
|
7
8
|
"signIn.matchingAccounts": "Matching accounts",
|
|
8
|
-
"signIn.privacy": "You sign in on your account's own site (Bluesky's, for most people)
|
|
9
|
+
"signIn.privacy": "You sign in on your account's own site (Bluesky's, for most people). Your sign-in is kept in this browser. {{site}} can use it only for what you approved, and signing out here ends it.",
|
|
10
|
+
"signIn.thisSite": "This site",
|
|
9
11
|
"signIn.createAccount": "Create one for free",
|
|
10
|
-
"elsewhere.text": "Rather not sign in? {{link}}",
|
|
11
|
-
"elsewhere.link": "
|
|
12
|
+
"elsewhere.text": "Rather not sign in here? {{link}}",
|
|
13
|
+
"elsewhere.link": "Respond on Bluesky →",
|
|
12
14
|
"actions.label": "Respond to this post",
|
|
13
15
|
"actions.likeAndRecommend": "Like on Bluesky, and recommend in apps such as Leaflet",
|
|
14
16
|
"actions.like": "Like",
|
|
@@ -97,6 +99,7 @@
|
|
|
97
99
|
"follow.following": "Following",
|
|
98
100
|
"follow.followAs": "Follow {{handle}} as {{me}}",
|
|
99
101
|
"follow.followingLabel": "You follow {{handle}}",
|
|
102
|
+
"follow.link": "Follow {{handle}} on Bluesky",
|
|
100
103
|
"subscribe.subscribe": "Subscribe",
|
|
101
104
|
"subscribe.subscribed": "✓ Subscribed",
|
|
102
105
|
"subscribe.subscribeLabel": "Subscribe with your Atmosphere account, to read new posts in apps such as Leaflet or Standard Reader",
|
|
@@ -108,10 +111,13 @@
|
|
|
108
111
|
"subscribe.feed": "RSS feed",
|
|
109
112
|
"subscribe.viewBluesky": "View on Bluesky",
|
|
110
113
|
"subscribe.standardReader": "View on Standard Reader",
|
|
111
|
-
"subscribe.
|
|
114
|
+
"subscribe.leaflet": "View on Leaflet",
|
|
115
|
+
"subscribe.panelText": "Subscribe to read new blog posts in apps such as {{leaflet}} or {{standardReader}}.",
|
|
116
|
+
"subscribe.panelTextFeed": "Subscribe to read new blog posts in apps such as {{leaflet}} or {{standardReader}}, or follow the feed in any feed reader.",
|
|
117
|
+
"subscribe.followNote": "To see everything {{handle}} posts, follow it on Bluesky.",
|
|
118
|
+
"subscribe.follow": "Follow on Bluesky",
|
|
112
119
|
"credit": "Powered by {{link}}",
|
|
113
120
|
"callback.signingIn": "Signing you in…",
|
|
114
|
-
"signIn.access": "Your sign-in is kept only in this browser. This site's server never sees it, so it can't post for you. Signing out here cancels it.",
|
|
115
121
|
"signIn.policy": "How this site handles your data",
|
|
116
122
|
"composer.needsPermission": "To comment here as {{handle}}, this site needs one more permission from your Atmosphere account.",
|
|
117
123
|
"comment.hide": "Hide",
|