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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (8 November 2026)
|
|
4
|
+
|
|
5
|
+
- **New `AtmosphereFollow` component:** "Follow @handle on Bluesky →", a link to the account's profile that becomes a Follow / ✓ Following button for signed-in readers (with `AtmosphereReactions` in the layout). Signed out it stays a link, so nobody is asked to sign in just to follow. Props, all optional: `account` (handle or DID; default the `standardSite` publication's account), `label`, `class`. The handle and DID are looked up when the page is built, with a warning if the account can't be found or isn't in `allowedAuthors`. The Subscribe menu's "Follow on Bluesky" uses it. New key `follow.link`.
|
|
6
|
+
- **New `AtmosphereSubscribe` component:** the Subscribe button and its ⌄ menu, to place anywhere, such as at the top of a blog's list of posts (with `AtmosphereReactions` in the layout, which works the button and offers signing in). Props, all optional: `align` (`"end"` opens the menu leftwards, for a button near the right-hand edge), `copyUrl` (what Copy link copies), `links` (extra menu items, e.g. "Add to Feedly") and `bluesky` (adds "View on Bluesky"; under a post, it's the post's thread). Shows nothing without `standardSite`. `AtmosphereComments` uses it for the one under each post, so the two can't drift apart; the menu's script moved into it.
|
|
7
|
+
- **"View on Leaflet"** in the menu (`https://leaflet.pub/lish/<did>/<rkey>`), which shows any Standard.site publication, not only ones made with Leaflet. New key `subscribe.leaflet`.
|
|
8
|
+
- **The menu says what Subscribe is for, and offers following.** Its note now reads "Subscribe to read new blog posts in apps such as Leaflet or Standard Reader, or follow the feed in any feed reader. To see everything @handle posts, follow it on Bluesky." (the feed part only with `feed`; the handle is the publication's account, looked up when the page is built). Under it, "Follow on Bluesky →" links to that account's profile, and `AtmosphereReactions` turns it into a Follow / Following button for signed-in readers. Subscribing still never follows. Keys: `subscribe.panelText` changed; new `subscribe.panelTextFeed`, `subscribe.followNote`, `subscribe.follow`. The note's line height is 1.5.
|
|
9
|
+
- **Several Follow buttons for one account stay in step** (say, one in a toolbar and one in the Subscribe menu): before, only the first link for an account became a button.
|
|
10
|
+
- **A clear "Liked" or "Reposted" after signing in.** When a reader presses Like or Repost while signed out, it's finished once they're back from signing in. Now a small bubble pops up over the button, with its icon enlarged and the word, then fades, so it's clear it went through. It appears only once the account host has accepted it, and readers who ask for less motion get a plain fade. In both components, for posts and comments. New `lib/client/done.ts`; styles `.atmo-done`; buttons are marked `data-busy` while their request runs.
|
|
11
|
+
- **Replying from `AtmosphereReactions` shows the post being replied to** above the box, as the Quote preview does, because the reply won't appear on that page. The post comes through the site's `post` route (as the Quote preview's does), and also gives "Replying to" its author's handle. The preview's markup is now a shared `PostPreview` component (`.atmo__parent` for this one).
|
|
12
|
+
- **"Replying to" never shows a DID.** In `AtmosphereReactions`, a post whose `data-atmosphere-url` names its author by DID (`https://bsky.app/profile/did:plc:…/post/…`) used to show "Replying to @did:plc:…". It now shows "Replying to someone" until the post being replied to has loaded (see above), then its author's `@handle`. If that fails, it stays "someone". Addresses with a handle are shown straight away, as before.
|
|
13
|
+
- **Clearer sign-in overlay wording.** The link for readers who'd rather not sign in now says "Respond on Bluesky →" (was "Reply on Bluesky instead →", even when they'd pressed Like or Repost), after "Rather not sign in here?". "How does signing in work?" is now one short paragraph, and no longer says the site "can't post for you", which overstated it: it says the sign-in is kept in the browser and that the site (named by its address, from `site`) can use it only for what the reader approved. "No account? Create one for free" has a line of its own. The overlay's small text has its own line height (1.7 for the two lines about accounts, 1.5 for the paragraph) rather than the site's body text's. Keys: new `signIn.noAccount` and `signIn.thisSite`; `signIn.access` removed; `signIn.anyAccount`, `signIn.privacy`, `elsewhere.text` and `elsewhere.link` changed; all eight languages.
|
|
14
|
+
|
|
15
|
+
## 0.7.0 (8 November 2026)
|
|
16
|
+
|
|
17
|
+
**Comments from people who show their posts only to signed-in readers.** Those accounts use Bluesky's "Discourage apps from showing my account to logged-out users".
|
|
18
|
+
|
|
19
|
+
- **Signed-in readers see them:** the reader's browser fetches those comments through the reader's own sign-in (`app.bsky.feed.getPosts`, already part of the approval, so nobody has to sign in again). They then show as normal comments, with Reply, Like and Repost. Signing out puts the placeholders back. The server still never sends their names, pictures or words; the page holds only each comment's address (its AT URI).
|
|
20
|
+
- **Signed-out readers get a clearer placeholder:** a blurred stand-in made of grey bars (never the real comment), with "Comment hidden" and a line saying to sign in to see it. Clicking it opens the sign-in overlay, and the page scrolls to the comment once they're back. The replies under it stay, as before. Styles: `.atmo-comment--withheld`, `.atmo-withheld__ghost`, `.atmo-withheld__cover`; `.atmo-comment__withheld` is gone.
|
|
21
|
+
- **Like and repost counts match the faces:** those accounts are no longer left out of the faces. Signed out, they show as a faceless silhouette (`.atmo-avatar-anonymous`) with no name or link; signed in, their real face, looked up through the site's `profile` route. The thread data gives only their DID (`withheld: true`).
|
|
22
|
+
- A broken avatar picture now falls back to the person's initial.
|
|
23
|
+
- New wording keys `comment.withheldTitle`, `comment.withheldSignedIn`, `signInFirst.view`, `reactions.hiddenPerson`; `comment.withheld` changed. Draft translations in all eight languages.
|
|
24
|
+
|
|
3
25
|
## 0.6.1 (7 November 2026)
|
|
4
26
|
|
|
5
27
|
- Uses the current AT Protocol library (`@atproto/api` 0.30, was 0.25), the same version as Atmosphere Publishing for Astro, so a site using both installs it once.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ See it on [saltedlolly.com](https://saltedlolly.com/2005/07/13/summer-jam-party-
|
|
|
25
25
|
3. **The sign-in stays in the reader's browser.** Your server never sees it: comments, likes and reposts go from the reader's browser straight to their own account host. So your server has no way to post for anyone, a copy of your server's files holds nothing about your readers, and when a reader signs out, the sign-in is cancelled at their host and deleted from their browser. That's it: nothing is left anywhere.
|
|
26
26
|
4. Your site asks, in one approval, only for what readers can do on it: to create posts, to like and repost, to delete their comments and to follow your accounts (and, when one of your own accounts signs in, to hide comments; with the `standardSite` option, to subscribe and recommend). Each is used only on your posts, the comments under them and your accounts. Readers can see and cancel it all at their account host.
|
|
27
27
|
|
|
28
|
-
Everyone sees Like, Repost, Quote and a box to write a comment. Pressing any of them signed out opens an overlay to sign in, and what they pressed is finished once they're back. Readers who'd rather not sign in can use the **
|
|
28
|
+
Everyone sees Like, Repost, Quote and a box to write a comment. Pressing any of them signed out opens an overlay to sign in, and what they pressed is finished once they're back. Readers who'd rather not sign in can use the **Respond on Bluesky** link in the overlay.
|
|
29
29
|
|
|
30
30
|
Images (avatars, link-card pictures), account searches (the suggestions under the sign-in box and for @mentions) and account lookups when signing in go through your server, so readers' browsers contact only their own account host, never Bluesky's other servers.
|
|
31
31
|
|
|
@@ -101,9 +101,21 @@ import AtmosphereReactions from "astro-atmosphere-comments/AtmosphereReactions.a
|
|
|
101
101
|
<AtmosphereReactions />
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
Give `data-atmosphere-url` with the author's handle, as above. An address with a DID instead (`/profile/did:plc:…/post/…`) works too: the reply box then shows "Replying to someone" until the post has loaded through your site, then the handle, and never the DID.
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
Each marked post gets a row of icons: Reply, Repost, Quote and Like, with counts for Repost and Like. Readers already signed in on your site like and repost straight away, and Reply and Quote open the comment box in an overlay (Reply with the post being replied to above the box, Quote with the quote preview); what they post goes to Bluesky, not your page, and the overlay says so with a link to it. Anyone else is offered the sign-in form in an overlay when they press Like or Repost (their like or repost is done when they come back, with a brief "Liked" or "Reposted" over the button), and Reply and Quote open the post on Bluesky. It works with posts from loaders such as [`@ascorbic/bluesky-loader`](https://www.npmjs.com/package/@ascorbic/bluesky-loader) or embeds such as [`@astro-community/astro-embed-bluesky`](https://www.npmjs.com/package/@astro-community/astro-embed-bluesky) (wrap the embed in the marked element). Only posts by your `allowedAuthors` can be liked or reposted through your site.
|
|
107
|
+
|
|
108
|
+
A **Follow** button: `AtmosphereFollow` shows "Follow @you.bsky.social on Bluesky →", a link to the account's Bluesky profile, and for signed-in readers it becomes a Follow button, showing "Following" once they do (a second press unfollows, as on Bluesky). Readers who aren't signed in just follow on Bluesky: they're never asked to sign in here for it.
|
|
109
|
+
|
|
110
|
+
```astro
|
|
111
|
+
---
|
|
112
|
+
import AtmosphereFollow from "astro-atmosphere-comments/AtmosphereFollow.astro";
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
<AtmosphereFollow account="you.bsky.social" />
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`account` takes a handle or a DID (default: your `standardSite` publication's account), `label` replaces the wording and `class` styles the link; the handle and DID are looked up when the page is built. Only your `allowedAuthors` can be followed through your site (others stay a link, with a warning in the build), and the account's owner, who can't follow themselves, keeps the plain link. It needs `AtmosphereReactions` in your layout. A link of your own works too, marked with the account's DID and handle:
|
|
107
119
|
|
|
108
120
|
```astro
|
|
109
121
|
<a href="https://bsky.app/profile/you.bsky.social"
|
|
@@ -112,7 +124,25 @@ A "follow me" link can become a **Follow** button for signed-in readers: mark it
|
|
|
112
124
|
|
|
113
125
|
**Publishing to Standard.site:** [Atmosphere Publishing for Astro](https://github.com/saltedlolly/astro-atmosphere-publishing) is the companion integration made to work with this one. It publishes your posts' Standard.site records, announces new posts on Bluesky and adds each page's `<link rel="site.standard.document">` tag, so the comment box finds each post's announcement without a `post` prop. Its `publicationFromState()` helper gives the `standardSite` option your publication (below). Other publishers work too, as long as they add that tag and set the record's `bskyPostRef`, as [Sequoia](https://sequoia.pub) does.
|
|
114
126
|
|
|
115
|
-
If you publish [Standard.site](https://standard.site) records, set the integration's `standardSite` option. Readers can then **subscribe** to your publication from your site, and read new posts in apps such as Leaflet or Standard Reader. `AtmosphereComments` shows a Subscribe button under each post, with Like and Repost
|
|
127
|
+
If you publish [Standard.site](https://standard.site) records, set the integration's `standardSite` option. Readers can then **subscribe** to your publication from your site, and read new posts in apps such as Leaflet or Standard Reader. `AtmosphereComments` shows a Subscribe button under each post, with Like and Repost, and a ⌄ menu of more ways to follow your site: a note on what subscribing does with a Follow on Bluesky link, Copy link, your feed (the option's `feed`), View on Bluesky (the post's thread), View on Standard Reader and View on Leaflet.
|
|
128
|
+
|
|
129
|
+
The same button and menu can go anywhere, such as at the top of your blog's list of posts, with `AtmosphereSubscribe` (and `AtmosphereReactions` in your layout, which works the button and offers signing in):
|
|
130
|
+
|
|
131
|
+
```astro
|
|
132
|
+
---
|
|
133
|
+
import AtmosphereSubscribe from "astro-atmosphere-comments/AtmosphereSubscribe.astro";
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
<AtmosphereSubscribe
|
|
137
|
+
align="end"
|
|
138
|
+
copyUrl="https://example.com/blog/"
|
|
139
|
+
links={[{ label: "Add to Feedly", href: "https://feedly.com/i/subscription/feed%2Fhttps%3A%2F%2Fexample.com%2Ffeed%2F" }]}
|
|
140
|
+
/>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
All its props are optional. `align="end"` lines the menu up with the button's right-hand edge, for a button near the right of the page (the default, `"start"`, lines it up on the left). `copyUrl` is what Copy link copies (default: your site's address). `links` adds items to the end of the menu. `bluesky` adds a View on Bluesky item going there (under each post it's the post's thread). The menu's note also suggests following your publication's account on Bluesky, with a link to its profile that becomes a Follow / Following button for signed-in readers; subscribing itself never follows. It shows nothing without the `standardSite` option, and needs JavaScript.
|
|
144
|
+
|
|
145
|
+
For a button of your own design instead (with `AtmosphereReactions` in your layout), mark it as below; it stays hidden without the option or JavaScript. Anyone not signed in is offered the sign-in form, and subscribed once they're back; a second press unsubscribes.
|
|
116
146
|
|
|
117
147
|
```astro
|
|
118
148
|
<button type="button" data-atmosphere-subscribe hidden>
|
|
@@ -159,7 +189,7 @@ Locally the integration uses the protocol's development mode, so it works withou
|
|
|
159
189
|
| `post` | The announcement: bsky.app address or AT URI. Optional with Standard.site (above). |
|
|
160
190
|
| `label` | The sign-in overlay's heading when commenting. Default "Sign in to leave a comment". |
|
|
161
191
|
| `authorLabel` | The badge on your own comments. Default "Author". |
|
|
162
|
-
| `blueskyLink` | "Rather not sign in here?
|
|
192
|
+
| `blueskyLink` | "Rather not sign in here? Respond on Bluesky →" in the sign-in overlay, linking to the announcement (with `u-syndication`, so microformats readers learn it's the post's copy on Bluesky). Default `true`; `blueskyLink={false}` hides it. |
|
|
163
193
|
| `locale`, `messages` | The same as the integration's options, for this one component. |
|
|
164
194
|
| `locale`, `messages` (also on `AtmosphereReactions`) | See Languages. |
|
|
165
195
|
| `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
|
|
@@ -223,7 +253,7 @@ The component fires (bubbling):
|
|
|
223
253
|
- **What's checked before anything is sent** (by the browser code, `lib/client/account.ts`): only your announcements and the comments under them can be replied to, liked, reposted or quoted; only a reader's own replies under your announcements can be deleted; only your `standardSite` publication can be subscribed to, and only documents in its account recommended; only your `allowedAuthors` can be followed; only an announcement's author can hide comments under it.
|
|
224
254
|
- **What your server does:** it shows the threads, looks up accounts and suggestions, finds the links and mentions in a comment before it's posted, and passes on avatars and link pictures it has just shown. None of it needs to know who a reader is. Each route that calls Bluesky is rate-limited per address, with an overall cap; requests that aren't simple reads must come from your own pages (checked by `Origin`). Readers' addresses are used only for those limits, never stored.
|
|
225
255
|
- **Trade-off:** account hosts trust browser sign-ins less than ones kept on a server, so they last less long (Bluesky's currently last about two weeks), and there's no way for a site to sign everyone out at once. For a reader who'd rather not trust your server, that's the point.
|
|
226
|
-
- **People who'd rather not be shown publicly:** Bluesky's setting "Discourage apps from showing my account to logged-out users" is respected, as your page is public.
|
|
256
|
+
- **People who'd rather not be shown publicly:** Bluesky's setting "Discourage apps from showing my account to logged-out users" is respected, as your page is public. For readers who aren't signed in, a comment from such an account shows as a blurred "Comment hidden" placeholder (made-up grey bars: no name, picture or words reach the page, only the comment's address), the replies under it stay, and the account appears in the faces of who liked and reposted as a faceless silhouette, so the counts still add up. Clicking the placeholder offers sign-in. Signed-in readers see these comments and faces as normal: their own browser fetches them with their own sign-in.
|
|
227
257
|
- **Moderation:** comments appear automatically. Hide one with Hide under it (signed in as the account that posted the announcement) or in the Bluesky app; it disappears here too. Show it again in the Bluesky app. You can also block the account, or add it to `hidden`.
|
|
228
258
|
|
|
229
259
|
## Limitations (for now)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Props:
|
|
8
8
|
// post the announcement's AT URI (at://did/app.bsky.feed.post/rkey).
|
|
9
9
|
// label the sign-in overlay's heading when commenting (default "Sign in to leave a comment").
|
|
10
|
-
// blueskyLink "Rather not sign in here?
|
|
10
|
+
// blueskyLink "Rather not sign in here? Respond on Bluesky →" in the
|
|
11
11
|
// sign-in overlay (default true; false hides it).
|
|
12
12
|
// credit a small "Powered by Atmosphere Comments for Astro" link under the
|
|
13
13
|
// comments (default true; false hides it).
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import settings from "../lib/settings";
|
|
39
39
|
import SignInForm from "./SignInForm.astro";
|
|
40
40
|
import Composer from "./Composer.astro";
|
|
41
|
+
import AtmosphereSubscribe from "./AtmosphereSubscribe.astro";
|
|
41
42
|
import "./atmosphere.css";
|
|
42
43
|
import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
|
|
43
44
|
import { escapeHtml } from "../lib/translate";
|
|
@@ -56,7 +57,7 @@ interface Props {
|
|
|
56
57
|
authorLabel?: string;
|
|
57
58
|
/** A small "Powered by Atmosphere Comments for Astro" link under the comments. Default true. */
|
|
58
59
|
credit?: boolean;
|
|
59
|
-
/** "Rather not sign in here?
|
|
60
|
+
/** "Rather not sign in here? Respond on Bluesky →" in the sign-in overlay. Default true. */
|
|
60
61
|
blueskyLink?: boolean;
|
|
61
62
|
/** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
|
|
62
63
|
locale?: string;
|
|
@@ -110,31 +111,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
110
111
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
|
|
111
112
|
<span>{t("actions.quote")}</span>
|
|
112
113
|
</button>
|
|
113
|
-
{/* With the `standardSite` option. */}
|
|
114
|
-
|
|
115
|
-
<div class="atmo-subscribe" data-subscribe-panel data-pagefind-ignore hidden>
|
|
116
|
-
<button type="button" class="atmo__action atmo-subscribe__button" data-atmosphere-subscribe hidden>
|
|
117
|
-
<span class="atmo-subscribe__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
|
|
118
|
-
<span data-atmosphere-subscribe-label>{t("subscribe.subscribe")}</span>
|
|
119
|
-
</button>
|
|
120
|
-
<button type="button" class="atmo__action atmo-subscribe__more" aria-label={t("subscribe.more")} title={t("subscribe.more")} aria-expanded="false" data-subscribe-more>
|
|
121
|
-
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
122
|
-
</button>
|
|
123
|
-
<div class="atmo-subscribe__menu" data-subscribe-menu hidden>
|
|
124
|
-
<p
|
|
125
|
-
class="atmo-subscribe__note"
|
|
126
|
-
set:html={th("subscribe.panelText", {
|
|
127
|
-
leaflet: link("https://leaflet.pub", "Leaflet"),
|
|
128
|
-
standardReader: link("https://standard-reader.app", "Standard Reader"),
|
|
129
|
-
})}
|
|
130
|
-
/>
|
|
131
|
-
<button type="button" data-subscribe-copy={settings.site} 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>
|
|
132
|
-
{settings.standardSite.feed && <a href={settings.standardSite.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>}
|
|
133
|
-
<a href={blueskyUrl || undefined} hidden={!blueskyUrl} 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>
|
|
134
|
-
<a href={`https://standard-reader.app/p/${settings.standardSite.publication.split("/")[2]}/${settings.standardSite.publication.split("/")[4]}`} 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>
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
)}
|
|
114
|
+
{/* With the `standardSite` option: Subscribe and its ⌄ menu. "View on Bluesky" is this post's thread. */}
|
|
115
|
+
<AtmosphereSubscribe bluesky={blueskyUrl || undefined} locale={lang} messages={messages} />
|
|
138
116
|
</div>
|
|
139
117
|
|
|
140
118
|
{/* A sign-in from before posting was asked for: one more permission. */}
|
|
@@ -195,7 +173,10 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
195
173
|
import { translate } from "../lib/translate";
|
|
196
174
|
import { enhanceHandleBox } from "../lib/client/handle-suggestions";
|
|
197
175
|
import { MAX, quotePreview, setUpComposer, type ComposerBehaviour, type Posted } from "../lib/client/composer";
|
|
198
|
-
import {
|
|
176
|
+
import { showDoneWhenPressed } from "../lib/client/done";
|
|
177
|
+
import { handleSignInForm, me as whoIsSignedIn, send, signIn, signOut, state, withheldPosts } from "../lib/client/account";
|
|
178
|
+
import publicSettings from "../lib/public-settings";
|
|
179
|
+
import { segments } from "../lib/richtext";
|
|
199
180
|
import { addSubscribeButtons, finishPendingSubscribe, forgetSubscribed, loadSubscribed } from "../lib/client/subscribe";
|
|
200
181
|
import type { Permission } from "../lib/permissions";
|
|
201
182
|
import { authorOf } from "../lib/uris";
|
|
@@ -268,7 +249,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
268
249
|
|
|
269
250
|
// The thread: likes and reposts above the comment box, and the comments
|
|
270
251
|
// below it, read through this site (routes/thread.ts).
|
|
271
|
-
type Person = { did: string; handle: string; displayName?: string; avatar?: string; url: string };
|
|
252
|
+
type Person = { did: string; handle: string; displayName?: string; avatar?: string; url: string; withheld?: true };
|
|
272
253
|
type Reply = {
|
|
273
254
|
uri: string;
|
|
274
255
|
url: string;
|
|
@@ -283,6 +264,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
283
264
|
};
|
|
284
265
|
type Thread = { uri: string; url: string; likeCount: number; repostCount: number; likes: Person[]; reposts: Person[]; replies: Reply[] };
|
|
285
266
|
let thread: Thread | null = null;
|
|
267
|
+
// Withheld comments and faces, as a signed-in reader sees them (by AT URI and DID).
|
|
268
|
+
const revealedReplies = new Map<string, Reply>();
|
|
269
|
+
const revealedPeople = new Map<string, Person>();
|
|
286
270
|
const reactionsBox = $<HTMLElement>("[data-reactions]");
|
|
287
271
|
const repliesList = $<HTMLOListElement>("[data-replies]");
|
|
288
272
|
const authorLabel = t("comment.author");
|
|
@@ -298,12 +282,24 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
298
282
|
if (text !== undefined) element.textContent = text;
|
|
299
283
|
return element;
|
|
300
284
|
};
|
|
301
|
-
const
|
|
302
|
-
if (person.avatar) return Object.assign(make("img", className), { src: person.avatar, alt: "", loading: "lazy" as const });
|
|
285
|
+
const initialOf = (person: Pick<Person, "handle" | "displayName">, className: string) => {
|
|
303
286
|
const initial = make("span", `${className} atmo-avatar-initial`, nameOf(person).replace("@", "").charAt(0).toUpperCase());
|
|
304
287
|
initial.setAttribute("aria-hidden", "true");
|
|
305
288
|
return initial;
|
|
306
289
|
};
|
|
290
|
+
// A faceless avatar, for someone who asks not to be shown to people who aren't signed in.
|
|
291
|
+
const anonymousAvatar = (className: string) => {
|
|
292
|
+
const face = make("span", `${className} atmo-avatar-anonymous`);
|
|
293
|
+
face.setAttribute("aria-hidden", "true");
|
|
294
|
+
return face;
|
|
295
|
+
};
|
|
296
|
+
const avatarOf = (person: Pick<Person, "handle" | "displayName" | "avatar"> & { withheld?: true }, className: string) => {
|
|
297
|
+
if (person.withheld) return anonymousAvatar(className);
|
|
298
|
+
if (!person.avatar) return initialOf(person, className);
|
|
299
|
+
const img = Object.assign(make("img", className), { src: person.avatar, alt: "", loading: "lazy" as const });
|
|
300
|
+
img.addEventListener("error", () => img.replaceWith(initialOf(person, className)), { once: true });
|
|
301
|
+
return img;
|
|
302
|
+
};
|
|
307
303
|
|
|
308
304
|
const renderReactions = () => {
|
|
309
305
|
if (!thread) return;
|
|
@@ -318,7 +314,16 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
318
314
|
group.dataset.reaction = kind;
|
|
319
315
|
group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
|
|
320
316
|
const faces = make("ul", "atmo-facepile");
|
|
321
|
-
for (const
|
|
317
|
+
for (const shown of people) {
|
|
318
|
+
const person = (shown.withheld && revealedPeople.get(shown.did)) || shown;
|
|
319
|
+
if (person.withheld) {
|
|
320
|
+
// Counted, but not identified: signed out, or not yet looked up.
|
|
321
|
+
const item = make("li");
|
|
322
|
+
item.title = t("reactions.hiddenPerson");
|
|
323
|
+
item.append(anonymousAvatar("atmo-facepile__avatar"));
|
|
324
|
+
faces.append(item);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
322
327
|
const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener", target: "_blank" });
|
|
323
328
|
link.setAttribute("aria-label", nameOf(person));
|
|
324
329
|
link.append(avatarOf(person, "atmo-facepile__avatar"));
|
|
@@ -339,9 +344,30 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
339
344
|
// Its author asks not to be shown to logged-out people: a placeholder,
|
|
340
345
|
// keeping the replies under it in place. (No AT URI, so no actions.)
|
|
341
346
|
if (reply.withheld) {
|
|
347
|
+
const revealed = revealedReplies.get(reply.uri);
|
|
348
|
+
if (revealed) return replyElement({ ...revealed, depth: reply.depth });
|
|
342
349
|
item.classList.add("atmo-comment--withheld");
|
|
350
|
+
if (reply.uri) item.dataset.withheldUri = reply.uri;
|
|
351
|
+
// A blurred, made-up comment (never the real one) under the explanation.
|
|
343
352
|
const article = make("article");
|
|
344
|
-
|
|
353
|
+
const ghost = make("div", "atmo-withheld__ghost");
|
|
354
|
+
ghost.setAttribute("aria-hidden", "true");
|
|
355
|
+
const ghostHeader = make("div", "atmo-comment__header");
|
|
356
|
+
const bar = (width: string) => Object.assign(make("span", "atmo-withheld__bar"), { style: `width: ${width}` });
|
|
357
|
+
const ghostNames = make("div", "atmo-withheld__names");
|
|
358
|
+
ghostNames.append(bar("9em"), bar("13em"));
|
|
359
|
+
ghostHeader.append(anonymousAvatar("atmo-comment__avatar"), ghostNames);
|
|
360
|
+
const ghostText = make("div", "atmo-withheld__text");
|
|
361
|
+
ghostText.append(bar("100%"), bar("92%"), bar("60%"));
|
|
362
|
+
ghost.append(ghostHeader, ghostText);
|
|
363
|
+
const cover = make("button", "atmo-withheld__cover");
|
|
364
|
+
cover.type = "button";
|
|
365
|
+
cover.append(make("strong", "", t("comment.withheldTitle")), make("span", "", t(isSignedIn ? "comment.withheldSignedIn" : "comment.withheld")));
|
|
366
|
+
cover.disabled = isSignedIn;
|
|
367
|
+
cover.addEventListener("click", () => {
|
|
368
|
+
if (!isSignedIn) askToSignIn("view", reply.uri || undefined);
|
|
369
|
+
});
|
|
370
|
+
article.append(ghost, cover);
|
|
345
371
|
item.append(article);
|
|
346
372
|
return item;
|
|
347
373
|
}
|
|
@@ -487,6 +513,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
487
513
|
reply: "signInFirst.reply",
|
|
488
514
|
"comment-like": "signInFirst.like",
|
|
489
515
|
"comment-repost": "signInFirst.repost",
|
|
516
|
+
view: "signInFirst.view",
|
|
490
517
|
};
|
|
491
518
|
const askToSignIn = (kind: string, uri?: string, problem?: string) =>
|
|
492
519
|
openSignIn(t(SIGN_IN_HEADINGS[kind]), `#atmo-do=${kind}${uri ? `:${encodeURIComponent(uri)}` : ""}`, problem);
|
|
@@ -621,43 +648,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
621
648
|
const askToSubscribe = (problem?: string) => openSignIn(t("subscribe.signIn"), "#atmo-subscribe", problem);
|
|
622
649
|
if (subscribePanel) addSubscribeButtons([...subscribePanel.querySelectorAll<HTMLButtonElement>("[data-atmosphere-subscribe]")], t, askToSubscribe);
|
|
623
650
|
|
|
624
|
-
// The ⌄ menu beside Subscribe
|
|
625
|
-
const moreButton = subscribePanel?.querySelector<HTMLButtonElement>("[data-subscribe-more]");
|
|
626
|
-
const moreMenu = subscribePanel?.querySelector<HTMLElement>("[data-subscribe-menu]");
|
|
627
|
-
if (subscribePanel && moreButton && moreMenu) {
|
|
628
|
-
const setMenu = (open: boolean) => {
|
|
629
|
-
moreMenu.hidden = !open;
|
|
630
|
-
moreButton.setAttribute("aria-expanded", String(open));
|
|
631
|
-
};
|
|
632
|
-
moreButton.addEventListener("click", () => {
|
|
633
|
-
setMenu(moreMenu.hidden);
|
|
634
|
-
if (!moreMenu.hidden) moreMenu.querySelector<HTMLElement>("[data-subscribe-copy]")?.focus();
|
|
635
|
-
});
|
|
636
|
-
document.addEventListener("click", (event) => {
|
|
637
|
-
if (!subscribePanel.contains(event.target as Node)) setMenu(false);
|
|
638
|
-
});
|
|
639
|
-
subscribePanel.addEventListener("keydown", (event) => {
|
|
640
|
-
if (event.key === "Escape" && !moreMenu.hidden) {
|
|
641
|
-
setMenu(false);
|
|
642
|
-
moreButton.focus();
|
|
643
|
-
}
|
|
644
|
-
});
|
|
645
|
-
const copy = moreMenu.querySelector<HTMLButtonElement>("[data-subscribe-copy]")!;
|
|
646
|
-
const copyText = copy.querySelector<HTMLElement>("[data-copy-label]")!;
|
|
647
|
-
const copyLabel = copyText.textContent;
|
|
648
|
-
copy.addEventListener("click", async () => {
|
|
649
|
-
try {
|
|
650
|
-
await navigator.clipboard.writeText(copy.dataset.subscribeCopy!);
|
|
651
|
-
copyText.textContent = copy.dataset.copied!;
|
|
652
|
-
window.setTimeout(() => {
|
|
653
|
-
copyText.textContent = copyLabel;
|
|
654
|
-
setMenu(false);
|
|
655
|
-
}, 1200);
|
|
656
|
-
} catch {
|
|
657
|
-
// Clipboard refused: the button stays as it was.
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
}
|
|
651
|
+
// The ⌄ menu beside Subscribe is worked by AtmosphereSubscribe itself.
|
|
661
652
|
|
|
662
653
|
// What the reader has liked or reposted, by post (filled in by loadState).
|
|
663
654
|
let known: Record<string, { liked: boolean; reposted: boolean }> = {};
|
|
@@ -680,6 +671,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
680
671
|
if (!isSignedIn) return askToSignIn(kind === "like" ? "comment-like" : "comment-repost", comment.dataset.atmosphereUri);
|
|
681
672
|
const on = button.getAttribute("aria-pressed") !== "true";
|
|
682
673
|
const before = Number(button.dataset.count || 0);
|
|
674
|
+
// Busy until the account host answers (lib/client/done.ts waits for it).
|
|
675
|
+
button.dataset.busy = "";
|
|
683
676
|
button.setAttribute("aria-pressed", String(on));
|
|
684
677
|
setCount(Math.max(0, before + (on ? 1 : -1)));
|
|
685
678
|
try {
|
|
@@ -690,6 +683,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
690
683
|
setCount(before);
|
|
691
684
|
const { error, permission } = (problem ?? {}) as { error?: string; permission?: string };
|
|
692
685
|
handleError(error ?? "error", say, permission);
|
|
686
|
+
} finally {
|
|
687
|
+
delete button.dataset.busy;
|
|
693
688
|
}
|
|
694
689
|
});
|
|
695
690
|
return button;
|
|
@@ -895,8 +890,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
895
890
|
return;
|
|
896
891
|
}
|
|
897
892
|
const comment = uri ? document.querySelector<HTMLElement>(`[data-atmosphere-uri="${CSS.escape(uri)}"]`) : null;
|
|
893
|
+
// Pressed for them, with a brief "Liked" or "Reposted" once it's gone through.
|
|
898
894
|
const press = (button: HTMLElement | null | undefined) => {
|
|
899
|
-
if (button
|
|
895
|
+
if (!button || button.getAttribute("aria-pressed") === "true") return;
|
|
896
|
+
showDoneWhenPressed(button, t(kind.endsWith("like") ? "actions.liked" : "actions.reposted"));
|
|
897
|
+
button.click();
|
|
900
898
|
};
|
|
901
899
|
(comment ?? this).scrollIntoView({ block: "center" });
|
|
902
900
|
if (kind === "like" || kind === "repost") press(reactButton(kind));
|
|
@@ -907,6 +905,53 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
907
905
|
else if (kind === "comment-repost" && comment) press(comment.querySelector<HTMLElement>(".atmo-comment-action[data-kind='repost']"));
|
|
908
906
|
};
|
|
909
907
|
|
|
908
|
+
// Draws the comments and faces again (after revealing, or signing out).
|
|
909
|
+
const redrawThread = () => {
|
|
910
|
+
if (!thread) return;
|
|
911
|
+
renderReactions();
|
|
912
|
+
repliesList.replaceChildren(...thread.replies.map(replyElement));
|
|
913
|
+
};
|
|
914
|
+
// Signed in: the comments and faces of people who ask not to be shown to
|
|
915
|
+
// people who aren't signed in, fetched as the reader and shown.
|
|
916
|
+
const revealWithheld = async () => {
|
|
917
|
+
if (!thread) return;
|
|
918
|
+
const uris = thread.replies.filter((reply) => reply.withheld && reply.uri && !revealedReplies.has(reply.uri)).map((reply) => reply.uri);
|
|
919
|
+
const dids = [...new Set([...thread.likes, ...thread.reposts].filter((person) => person.withheld && person.did && !revealedPeople.has(person.did)).map((person) => person.did))].slice(0, 20);
|
|
920
|
+
if (!uris.length && !dids.length) return;
|
|
921
|
+
const [posts, people] = await Promise.all([
|
|
922
|
+
withheldPosts(uris),
|
|
923
|
+
Promise.all(
|
|
924
|
+
dids.map((did) =>
|
|
925
|
+
fetch(`${base}/profile?did=${encodeURIComponent(did)}`)
|
|
926
|
+
.then((response) => (response.ok ? (response.json() as Promise<Pick<Person, "did" | "handle" | "displayName" | "avatar">>) : null))
|
|
927
|
+
.catch(() => null),
|
|
928
|
+
),
|
|
929
|
+
),
|
|
930
|
+
]);
|
|
931
|
+
if (!isSignedIn) return;
|
|
932
|
+
for (const found of posts) {
|
|
933
|
+
const handle = found.author.handle;
|
|
934
|
+
const profileUrl = `https://bsky.app/profile/${encodeURIComponent(handle)}`;
|
|
935
|
+
revealedReplies.set(found.uri, {
|
|
936
|
+
uri: found.uri,
|
|
937
|
+
url: `${profileUrl}/post/${found.uri.split("/").pop()}`,
|
|
938
|
+
author: { did: found.author.did, handle, displayName: found.author.displayName, avatar: `${base}/media?avatar=${encodeURIComponent(found.author.did)}`, url: profileUrl },
|
|
939
|
+
byAuthor: publicSettings.allowedAuthors.includes(found.author.did),
|
|
940
|
+
createdAt: found.record.createdAt ?? "",
|
|
941
|
+
text: segments(found.record.text ?? "", found.record.facets),
|
|
942
|
+
depth: 0,
|
|
943
|
+
likeCount: found.likeCount,
|
|
944
|
+
repostCount: found.repostCount,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
for (const found of people) {
|
|
948
|
+
if (found?.did && found.handle && found.handle !== found.did) {
|
|
949
|
+
revealedPeople.set(found.did, { ...found, url: `https://bsky.app/profile/${encodeURIComponent(found.handle)}` });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
redrawThread();
|
|
953
|
+
};
|
|
954
|
+
|
|
910
955
|
const show = async (signed: boolean, handle?: string, displayName?: string, avatar?: string) => {
|
|
911
956
|
if (handle) {
|
|
912
957
|
$<HTMLElement>("[data-preview-name]").textContent = displayName || handle;
|
|
@@ -931,6 +976,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
931
976
|
permissionPanel.hidden = true;
|
|
932
977
|
setPressed("like", false);
|
|
933
978
|
setPressed("repost", false);
|
|
979
|
+
if (revealedReplies.size || revealedPeople.size) {
|
|
980
|
+
revealedReplies.clear();
|
|
981
|
+
revealedPeople.clear();
|
|
982
|
+
redrawThread();
|
|
983
|
+
}
|
|
934
984
|
resumePending(false);
|
|
935
985
|
return closeReplies();
|
|
936
986
|
}
|
|
@@ -940,6 +990,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
940
990
|
permissionPanel.hidden = can("post");
|
|
941
991
|
// Which account, as they may have more than one.
|
|
942
992
|
$<HTMLElement>("[data-permission-text]").textContent = t("composer.needsPermission", { handle: handle ? `@${handle}` : "" });
|
|
993
|
+
await revealWithheld().catch(() => undefined);
|
|
943
994
|
addCommentActions();
|
|
944
995
|
addDeleteButtons();
|
|
945
996
|
await loadState();
|
|
@@ -1000,6 +1051,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1000
1051
|
reactButton(kind).addEventListener("click", async () => {
|
|
1001
1052
|
if (!isSignedIn) return askToSignIn(kind);
|
|
1002
1053
|
const on = reactButton(kind).getAttribute("aria-pressed") !== "true";
|
|
1054
|
+
// Busy until the account host answers (lib/client/done.ts waits for it).
|
|
1055
|
+
reactButton(kind).dataset.busy = "";
|
|
1003
1056
|
setPressed(kind, on);
|
|
1004
1057
|
try {
|
|
1005
1058
|
// Liking the post also recommends the page's Standard.site record, if it has one.
|
|
@@ -1021,6 +1074,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
|
|
|
1021
1074
|
} catch {
|
|
1022
1075
|
setPressed(kind, !on);
|
|
1023
1076
|
say(message("error"));
|
|
1077
|
+
} finally {
|
|
1078
|
+
delete reactButton(kind).dataset.busy;
|
|
1024
1079
|
}
|
|
1025
1080
|
});
|
|
1026
1081
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
// "Follow @handle on Bluesky →": a link to the account's Bluesky profile,
|
|
3
|
+
// which AtmosphereReactions (in the layout) turns into a Follow / ✓ Following
|
|
4
|
+
// button for signed-in readers. Signed out it stays a link, so readers follow
|
|
5
|
+
// on Bluesky itself: nobody is asked to sign in here just to follow.
|
|
6
|
+
//
|
|
7
|
+
// Props (all optional):
|
|
8
|
+
// account the account to follow, by DID or handle (default: the
|
|
9
|
+
// `standardSite` publication's account). Must be one of the
|
|
10
|
+
// integration's `allowedAuthors` for the button to work.
|
|
11
|
+
// label the link's text, instead of "Follow @handle on Bluesky"
|
|
12
|
+
// class classes for the link
|
|
13
|
+
// locale, messages as for AtmosphereComments
|
|
14
|
+
import settings from "../lib/settings";
|
|
15
|
+
import { messagesFor, pickLocale, translate } from "../lib/i18n";
|
|
16
|
+
import { profileFor } from "../lib/profile";
|
|
17
|
+
import { resolveHandle } from "../lib/resolve";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
account?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
locale?: string;
|
|
24
|
+
messages?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
|
|
28
|
+
const messages = messagesFor(lang, { ...settings.messages, ...Astro.props.messages });
|
|
29
|
+
|
|
30
|
+
// The DID and handle, looked up when the page is built.
|
|
31
|
+
const given = (Astro.props.account ?? settings.standardSite?.publication?.split("/")[2] ?? "").replace(/^@/, "");
|
|
32
|
+
let did = "";
|
|
33
|
+
let handle = "";
|
|
34
|
+
if (given.startsWith("did:")) {
|
|
35
|
+
did = given;
|
|
36
|
+
const profile = await profileFor(did).catch(() => null);
|
|
37
|
+
if (profile && profile.handle !== did) handle = profile.handle;
|
|
38
|
+
} else if (given) {
|
|
39
|
+
handle = given;
|
|
40
|
+
did = await resolveHandle(given).catch(() => "");
|
|
41
|
+
}
|
|
42
|
+
if (given && !did) {
|
|
43
|
+
console.warn(`[atmosphere-comments] AtmosphereFollow: couldn't find the account ${given}, so it's a plain link.`);
|
|
44
|
+
} else if (did && !settings.allowedAuthors.includes(did)) {
|
|
45
|
+
console.warn(
|
|
46
|
+
`[atmosphere-comments] AtmosphereFollow: ${handle || did} isn't in allowedAuthors, so it stays a link for everyone.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const label =
|
|
50
|
+
Astro.props.label ??
|
|
51
|
+
(handle ? translate(messages, lang, "follow.link", { handle: `@${handle}` }) : translate(messages, lang, "subscribe.follow"));
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
{(did || handle) && (
|
|
55
|
+
<a
|
|
56
|
+
class:list={["atmo-follow-link", Astro.props.class]}
|
|
57
|
+
href={`https://bsky.app/profile/${handle || did}`}
|
|
58
|
+
target="_blank"
|
|
59
|
+
rel="noopener"
|
|
60
|
+
data-atmosphere-follow={did && settings.allowedAuthors.includes(did) ? did : undefined}
|
|
61
|
+
data-atmosphere-follow-handle={handle || undefined}
|
|
62
|
+
>{label} →</a>
|
|
63
|
+
)}
|