astro-atmosphere-comments 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +36 -27
  3. package/components/AtmosphereComments.astro +378 -117
  4. package/components/AtmosphereReactions.astro +145 -98
  5. package/components/Composer.astro +5 -2
  6. package/components/SignInForm.astro +65 -7
  7. package/components/atmosphere.css +267 -2
  8. package/index.d.ts +11 -0
  9. package/index.mjs +53 -32
  10. package/lib/client/account.ts +661 -0
  11. package/lib/client/composer.ts +23 -10
  12. package/lib/client/subscribe.ts +106 -0
  13. package/lib/http.ts +28 -0
  14. package/lib/permissions.ts +132 -0
  15. package/lib/public-settings.ts +20 -0
  16. package/lib/quoted.ts +4 -0
  17. package/lib/settings.ts +2 -0
  18. package/lib/thread.ts +36 -6
  19. package/lib/uris.ts +39 -0
  20. package/locales/de.json +20 -4
  21. package/locales/en.json +48 -13
  22. package/locales/es.json +20 -4
  23. package/locales/fr.json +20 -4
  24. package/locales/it.json +20 -4
  25. package/locales/ja.json +20 -4
  26. package/locales/nl.json +20 -4
  27. package/locales/pt.json +20 -4
  28. package/package.json +2 -7
  29. package/routes/callback.astro +42 -0
  30. package/routes/client-metadata.json.ts +3 -2
  31. package/routes/following.ts +28 -0
  32. package/routes/identity.ts +69 -0
  33. package/routes/media.ts +7 -10
  34. package/routes/post.ts +2 -1
  35. package/routes/profile.ts +20 -0
  36. package/routes/richtext.ts +30 -0
  37. package/routes/thread.ts +23 -3
  38. package/routes/typeahead.ts +7 -23
  39. package/virtual.d.ts +5 -0
  40. package/bin/cli.mjs +0 -31
  41. package/lib/oauth.ts +0 -110
  42. package/lib/reader.ts +0 -130
  43. package/lib/store.ts +0 -46
  44. package/lib/visitor.ts +0 -42
  45. package/routes/callback.ts +0 -30
  46. package/routes/comment.ts +0 -97
  47. package/routes/delete.ts +0 -41
  48. package/routes/follow.ts +0 -71
  49. package/routes/jwks.json.ts +0 -13
  50. package/routes/login.ts +0 -58
  51. package/routes/logout.ts +0 -20
  52. package/routes/me.ts +0 -12
  53. package/routes/react.ts +0 -48
  54. package/routes/state.ts +0 -24
@@ -118,6 +118,222 @@ dialog.atmo:not([open]) {
118
118
  gap: 8px;
119
119
  }
120
120
 
121
+ /* The sign-in overlay, shared by both components. */
122
+ dialog.atmo.atmo-signin {
123
+ /* Centred on the screen, whatever margin a site gives .atmo. */
124
+ margin: auto;
125
+ width: min(92vw, 520px);
126
+ padding: 22px 22px 18px;
127
+ border: 1px solid var(--atmo-border, #8886);
128
+ border-radius: calc(var(--atmo-radius, 10px) + 6px);
129
+ background: var(--atmo-dialog-bg, var(--atmo-menu-bg, Canvas));
130
+ color: var(--atmo-text, CanvasText);
131
+ box-shadow: 0 24px 60px rgb(0 0 0 / 0.35);
132
+ }
133
+
134
+ /* The sign-in form is short, and its account suggestions can reach past the
135
+ overlay's edge. (The comment box's overlay can be tall, so it keeps scrolling.) */
136
+ dialog.atmo.atmo-signin:has(> .atmo__login) {
137
+ overflow: visible;
138
+ }
139
+
140
+ /* As wide as the form, so names and handles fit on one line. */
141
+ .atmo-signin .atmo__suggestions {
142
+ min-width: min(474px, calc(92vw - 46px));
143
+ }
144
+
145
+ .atmo-signin::backdrop {
146
+ background: rgb(0 0 0 / 0.55);
147
+ }
148
+
149
+ .atmo-signin .atmo__login {
150
+ margin-top: 0;
151
+ }
152
+
153
+ .atmo-signin__close {
154
+ position: absolute;
155
+ top: 8px;
156
+ right: 10px;
157
+ padding: 4px 8px;
158
+ border: 0;
159
+ background: none;
160
+ color: var(--atmo-muted, inherit);
161
+ font: inherit;
162
+ font-size: 1.4em;
163
+ line-height: 1;
164
+ cursor: pointer;
165
+ }
166
+
167
+ .atmo-signin__status {
168
+ margin: 10px 0 0;
169
+ color: var(--atmo-muted, inherit);
170
+ font-size: 0.9em;
171
+ }
172
+
173
+ .atmo-signin__status:empty {
174
+ display: none;
175
+ }
176
+
177
+ /* Signed out: looks like the comment box, and opens the sign-in overlay. */
178
+ .atmo__prompt-box {
179
+ display: grid;
180
+ gap: 8px;
181
+ max-width: 560px;
182
+ margin-top: 14px;
183
+ }
184
+
185
+ .atmo__prompt-box[hidden] {
186
+ display: none;
187
+ }
188
+
189
+ .atmo__prompt-box .atmo__button:disabled {
190
+ cursor: not-allowed;
191
+ }
192
+
193
+ .atmo__prompt {
194
+ /* A button centres its text; a text box starts at the top left. */
195
+ display: flex;
196
+ align-items: flex-start;
197
+ width: 100%;
198
+ min-height: 84px;
199
+ padding: 12px 14px;
200
+ border: 1px solid var(--atmo-border, #8886);
201
+ border-radius: var(--atmo-radius, 10px);
202
+ background: var(--atmo-field-bg, transparent);
203
+ color: var(--atmo-muted, inherit);
204
+ font: inherit;
205
+ text-align: left;
206
+ vertical-align: top;
207
+ cursor: text;
208
+ }
209
+
210
+ .atmo__prompt:hover,
211
+ .atmo__prompt:focus-visible {
212
+ border-color: var(--atmo-accent, #1185fe);
213
+ }
214
+
215
+ /* Subscribe and its menu: a split button, with Like and Repost once signed
216
+ in, or on its own above the sign-in form. */
217
+ .atmo-subscribe {
218
+ position: relative;
219
+ display: inline-flex;
220
+ }
221
+
222
+ .atmo-subscribe[hidden] {
223
+ display: none;
224
+ }
225
+
226
+ .atmo-subscribe .atmo-subscribe__button {
227
+ border-radius: 999px 0 0 999px;
228
+ }
229
+
230
+ .atmo-subscribe__button[hidden] {
231
+ display: none;
232
+ }
233
+
234
+ .atmo-subscribe__button:disabled {
235
+ opacity: 0.6;
236
+ cursor: progress;
237
+ }
238
+
239
+ .atmo-subscribe__logo {
240
+ flex: none;
241
+ width: 18px;
242
+ height: 18px;
243
+ background-color: currentColor;
244
+ mask: var(--atmo-logo) center / contain no-repeat;
245
+ }
246
+
247
+ .atmo-subscribe .atmo-subscribe__more {
248
+ padding: 0 10px 0 8px;
249
+ border-left: 0;
250
+ border-radius: 0 999px 999px 0;
251
+ }
252
+
253
+ /* Subscribed: both halves look like one button. */
254
+ .atmo-subscribe:has([aria-pressed="true"]) .atmo-subscribe__more {
255
+ border-color: color-mix(in srgb, var(--atmo-accent, #1185fe) 40%, transparent);
256
+ background: color-mix(in srgb, var(--atmo-accent, #1185fe) 8%, transparent);
257
+ color: var(--atmo-accent, #1185fe);
258
+ }
259
+
260
+ .atmo-subscribe .atmo-subscribe__more svg {
261
+ width: 15px;
262
+ height: 15px;
263
+ }
264
+
265
+ .atmo-subscribe__menu {
266
+ position: absolute;
267
+ z-index: 20;
268
+ top: calc(100% + 6px);
269
+ left: 0;
270
+ display: grid;
271
+ width: min(260px, 80vw);
272
+ padding: 6px;
273
+ border: 1px solid var(--atmo-border, #8886);
274
+ border-radius: var(--atmo-radius, 10px);
275
+ background: var(--atmo-menu-bg, Canvas);
276
+ box-shadow: 0 12px 30px rgb(0 0 0 / 0.25);
277
+ }
278
+
279
+ .atmo-subscribe__menu[hidden] {
280
+ display: none;
281
+ }
282
+
283
+ .atmo-subscribe__note {
284
+ margin: 0 0 6px;
285
+ padding: 6px 10px 10px;
286
+ border-bottom: 1px solid var(--atmo-border, #8886);
287
+ color: var(--atmo-muted, inherit);
288
+ font-size: 0.85em;
289
+ }
290
+
291
+ .atmo-subscribe__menu > a[hidden] {
292
+ display: none;
293
+ }
294
+
295
+ .atmo-subscribe__menu svg {
296
+ flex: none;
297
+ width: 16px;
298
+ height: 16px;
299
+ fill: none;
300
+ stroke: currentColor;
301
+ stroke-width: 1.8;
302
+ stroke-linecap: round;
303
+ stroke-linejoin: round;
304
+ }
305
+
306
+ .atmo-subscribe__menu svg circle,
307
+ .atmo-subscribe__menu svg.atmo-subscribe__filled {
308
+ fill: currentColor;
309
+ stroke: none;
310
+ }
311
+
312
+ .atmo-subscribe__menu > a,
313
+ .atmo-subscribe__menu > button {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 9px;
317
+ padding: 7px 10px;
318
+ border: 0;
319
+ border-radius: calc(var(--atmo-radius, 10px) - 4px);
320
+ background: none;
321
+ color: var(--atmo-text, inherit);
322
+ font: inherit;
323
+ font-size: 0.9em;
324
+ text-align: left;
325
+ text-decoration: none;
326
+ cursor: pointer;
327
+ }
328
+
329
+ .atmo-subscribe__menu > a:hover,
330
+ .atmo-subscribe__menu > a:focus-visible,
331
+ .atmo-subscribe__menu > button:hover,
332
+ .atmo-subscribe__menu > button:focus-visible {
333
+ background: var(--atmo-field-bg, #8882);
334
+ color: var(--atmo-accent, #1185fe);
335
+ }
336
+
121
337
  .atmo__action {
122
338
  display: inline-flex;
123
339
  align-items: center;
@@ -149,7 +365,9 @@ dialog.atmo:not([open]) {
149
365
  }
150
366
 
151
367
  .atmo__action[aria-pressed="true"] {
152
- border-color: var(--atmo-accent, #1185fe);
368
+ /* Softer than the text, like the outlines of the buttons beside it. */
369
+ border-color: color-mix(in srgb, var(--atmo-accent, #1185fe) 40%, transparent);
370
+ background: color-mix(in srgb, var(--atmo-accent, #1185fe) 8%, transparent);
153
371
  color: var(--atmo-accent, #1185fe);
154
372
  }
155
373
 
@@ -358,6 +576,10 @@ dialog.atmo:not([open]) {
358
576
  overflow-wrap: anywhere;
359
577
  }
360
578
 
579
+ .atmo__preview-text:empty {
580
+ display: none;
581
+ }
582
+
361
583
  .atmo__preview-text.is-placeholder {
362
584
  color: var(--atmo-muted, inherit);
363
585
  font-style: italic;
@@ -386,6 +608,13 @@ dialog.atmo:not([open]) {
386
608
  padding: 10px 12px;
387
609
  }
388
610
 
611
+ .atmo__card [data-card-title] {
612
+ display: -webkit-box;
613
+ overflow: hidden;
614
+ -webkit-box-orient: vertical;
615
+ -webkit-line-clamp: 2;
616
+ }
617
+
389
618
  .atmo__card [data-card-description] {
390
619
  display: -webkit-box;
391
620
  overflow: hidden;
@@ -393,10 +622,25 @@ dialog.atmo:not([open]) {
393
622
  -webkit-line-clamp: 2;
394
623
  }
395
624
 
396
- .atmo__card-domain {
625
+ .atmo__card .atmo__card-domain {
626
+ display: flex;
627
+ align-items: center;
628
+ gap: 4px;
629
+ margin-top: 6px;
630
+ padding-top: 6px;
631
+ border-top: 1px solid var(--atmo-border, #8886);
397
632
  font-size: 0.85em;
398
633
  }
399
634
 
635
+ .atmo__card-domain svg {
636
+ flex: none;
637
+ width: 12px;
638
+ height: 12px;
639
+ fill: none;
640
+ stroke: currentColor;
641
+ stroke-width: 1.8;
642
+ }
643
+
400
644
  /* The likes and reposts, and the comments. These are drawn by the script,
401
645
  and wrapped in :where() so they carry no weight: any rule a site writes
402
646
  for these classes wins. */
@@ -610,3 +854,24 @@ dialog.atmo:not([open]) {
610
854
  .atmo-comment-action[data-kind="like"][aria-pressed="true"] svg {
611
855
  fill: currentColor;
612
856
  }
857
+
858
+ /* "One more permission" under posts, for readers who signed in only to like
859
+ and repost. */
860
+ .atmo__permission {
861
+ display: grid;
862
+ gap: 8px;
863
+ max-width: 560px;
864
+ margin-top: 14px;
865
+ }
866
+
867
+ .atmo__permission p {
868
+ margin: 0;
869
+ }
870
+
871
+ /* In place of a comment whose author asks not to be shown to logged-out people. */
872
+ :where(.atmo-comment__withheld) {
873
+ margin: 0;
874
+ color: var(--atmo-muted, inherit);
875
+ font-size: 0.9em;
876
+ font-style: italic;
877
+ }
package/index.d.ts CHANGED
@@ -16,6 +16,17 @@ export type AtmosphereCommentsOptions = {
16
16
  /** Your privacy policy and terms, if you have them (absolute addresses). */
17
17
  policyUri?: string;
18
18
  tosUri?: string;
19
+ /**
20
+ * Your Standard.site publication, if you publish one. Turns on the Subscribe
21
+ * button, and makes Like under a post also recommend its Standard.site
22
+ * record. Readers are asked for this in the same approval as everything else.
23
+ */
24
+ standardSite?: {
25
+ /** The publication record's AT URI: at://did:…/site.standard.publication/… */
26
+ publication: string;
27
+ /** Your feed's address, for the menu beside Subscribe, e.g. "/feed/". */
28
+ feed?: string;
29
+ };
19
30
  /**
20
31
  * Comments never to show: bsky.app post or profile addresses, AT URIs,
21
32
  * handles or DIDs (on top of replies hidden on Bluesky and moderation labels).
package/index.mjs CHANGED
@@ -4,44 +4,39 @@
4
4
  // replies to the post's announcement, written to the reader's own account, so
5
5
  // they can delete them there at any time.
6
6
  //
7
- // Sign-in uses AT Protocol OAuth straight to the reader's own account host:
8
- // no third-party login service is involved. This site only ever holds a
9
- // grant to create posts and to create and delete likes and reposts, plus, if
10
- // a reader allows it when they first delete a comment, to delete posts (see
11
- // SCOPE and DELETE_SCOPE in lib/oauth.ts).
7
+ // Sign-in uses AT Protocol OAuth straight to the reader's own account host,
8
+ // as a browser client: the sign-in is kept only in the reader's browser, and
9
+ // the site's server never sees it, so the server can't act for anyone.
10
+ // Signing out cancels it at the reader's host. See lib/client/account.ts.
12
11
  //
13
12
  // It adds /oauth-client-metadata.json at the site's root (what account hosts
14
- // read to know the site), and these routes under `base` (default /atmosphere):
15
- // jwks.json the public half of the site's signing key
16
- // login (POST) starts sign-in with the reader's handle
17
- // callback where the reader's host sends them back
18
- // logout (POST) signs out and revokes the site's grant
19
- // me who's signed in (for the comment box)
20
- // comment (POST) posts a comment (or quote post) as the reader
21
- // state whether the reader has liked or reposted
22
- // react (POST) likes or reposts, or undoes that
23
- // post the announcement, for the Quote preview
24
- // media avatars and link-card pictures, via this site
25
- // typeahead @mention suggestions as the reader types
26
- // thread the comments, likes and reposts to show
27
- // and a component, AtmosphereComments.astro, to put under each post.
13
+ // read to know the site), and these routes under `base` (default /atmosphere),
14
+ // none of which act for a reader:
15
+ // callback the page readers come back to after approving
16
+ // identity looks up an account for signing in
17
+ // profile a reader's public profile, for the comment box
18
+ // richtext links, @mentions and #hashtags in a comment, before it's posted
19
+ // following whether an account follows one of the site's accounts
20
+ // post the announcement, for the Quote preview
21
+ // media avatars and link-card pictures, via this site
22
+ // typeahead account suggestions as the reader types
23
+ // thread the comments, likes and reposts to show
24
+ // and two components: AtmosphereComments.astro under each post, and
25
+ // AtmosphereReactions.astro once in the layout.
28
26
  //
29
27
  // It needs an on-demand (server) adapter such as @astrojs/node.
30
28
  import { fileURLToPath } from "node:url";
31
29
 
32
30
  const VIRTUAL_ID = "virtual:atmosphere-comments/settings";
31
+ // The settings that are safe for readers' browsers (lib/public-settings.ts).
32
+ const PUBLIC_ID = "virtual:atmosphere-comments/public-settings";
33
33
 
34
34
  const ROUTES = {
35
- "jwks.json": "jwks.json.ts",
36
- login: "login.ts",
37
- callback: "callback.ts",
38
- logout: "logout.ts",
39
- me: "me.ts",
40
- comment: "comment.ts",
41
- state: "state.ts",
42
- react: "react.ts",
43
- delete: "delete.ts",
44
- follow: "follow.ts",
35
+ callback: "callback.astro",
36
+ identity: "identity.ts",
37
+ profile: "profile.ts",
38
+ richtext: "richtext.ts",
39
+ following: "following.ts",
45
40
  post: "post.ts",
46
41
  media: "media.ts",
47
42
  typeahead: "typeahead.ts",
@@ -49,6 +44,7 @@ const ROUTES = {
49
44
  };
50
45
 
51
46
  const DID = /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$/;
47
+ const PUBLICATION = /^at:\/\/did:[a-z0-9]+:[a-zA-Z0-9._:%-]+\/site\.standard\.publication\/[a-zA-Z0-9._~-]+$/;
52
48
 
53
49
  /**
54
50
  * @param {import("./index").AtmosphereCommentsOptions} options
@@ -62,6 +58,10 @@ export default function atmosphereComments(options) {
62
58
  "atmosphere-comments: `allowedAuthors` must list the DIDs (did:plc:… or did:web:…) of the accounts that post your announcements.",
63
59
  );
64
60
  }
61
+ const standardSite = options.standardSite;
62
+ if (standardSite && !PUBLICATION.test(standardSite.publication ?? "")) {
63
+ throw new Error("atmosphere-comments: `standardSite.publication` must be your publication's AT URI (at://did:…/site.standard.publication/…).");
64
+ }
65
65
  const base = `/${(options.base ?? "/atmosphere").replace(/^\/+|\/+$/g, "")}`;
66
66
  return {
67
67
  name: "atmosphere-comments",
@@ -80,6 +80,7 @@ export default function atmosphereComments(options) {
80
80
  logoUri: options.logoUri,
81
81
  policyUri: options.policyUri,
82
82
  tosUri: options.tosUri,
83
+ standardSite,
83
84
  hidden: options.hidden ?? [],
84
85
  locale: options.locale,
85
86
  messages: options.messages ?? {},
@@ -102,14 +103,34 @@ export default function atmosphereComments(options) {
102
103
  });
103
104
  }
104
105
 
105
- // The routes and component read the settings from a virtual module.
106
+ const publicSettings = {
107
+ clientName: settings.clientName,
108
+ allowedAuthors,
109
+ base,
110
+ site: settings.site,
111
+ logoUri: settings.logoUri,
112
+ policyUri: settings.policyUri,
113
+ tosUri: settings.tosUri,
114
+ standardSite,
115
+ };
116
+
117
+ // The routes and components read the settings from virtual modules.
106
118
  updateConfig({
107
119
  vite: {
120
+ // Readers' browsers load the sign-in library only when needed, so
121
+ // Vite's dev server wouldn't find it to bundle it in advance, and
122
+ // the old-style modules it depends on then fail in the browser.
123
+ optimizeDeps: { include: ["astro-atmosphere-comments > @atproto/oauth-client-browser"] },
108
124
  plugins: [
109
125
  {
110
126
  name: "atmosphere-comments-settings",
111
- resolveId: (id) => (id === VIRTUAL_ID ? `\0${VIRTUAL_ID}` : undefined),
112
- load: (id) => (id === `\0${VIRTUAL_ID}` ? `export default ${JSON.stringify(settings)};` : undefined),
127
+ resolveId: (id) => (id === VIRTUAL_ID || id === PUBLIC_ID ? `\0${id}` : undefined),
128
+ load: (id) =>
129
+ id === `\0${VIRTUAL_ID}`
130
+ ? `export default ${JSON.stringify(settings)};`
131
+ : id === `\0${PUBLIC_ID}`
132
+ ? `export default ${JSON.stringify(publicSettings)};`
133
+ : undefined,
113
134
  },
114
135
  ],
115
136
  },