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
@@ -2,6 +2,7 @@
2
2
  // posting (as a reply, or a quote post), and the quote preview. Used by
3
3
  // AtmosphereComments (its main box and every reply box) and by the reply and
4
4
  // quote overlay of AtmosphereReactions. The markup is Composer.astro.
5
+ import { send } from "./account";
5
6
 
6
7
  export const MAX = 300; // Bluesky's limit for a post, in characters as people count them
7
8
  const graphemes = new Intl.Segmenter();
@@ -9,7 +10,7 @@ export const count = (text: string) => [...graphemes.segment(text.trim())].lengt
9
10
 
10
11
  export type Suggestion = { handle: string; displayName?: string; avatar?: string };
11
12
  export type Person = { did: string; handle: string; displayName?: string; avatar?: string };
12
- /** What the comment route answers with once something is posted. */
13
+ /** What posting a comment (lib/client/account.ts) answers with. */
13
14
  export type Posted = {
14
15
  uri: string;
15
16
  url: string;
@@ -31,11 +32,12 @@ export type ComposerContext = {
31
32
  export type ComposerBehaviour = {
32
33
  /** The post being replied to or quoted (the announcement, or another post by the site's accounts). */
33
34
  root: () => string;
34
- /** More for the comment route: { parent } for a reply to a comment, { quote: true } for a quote post. */
35
+ /** More for posting: { parent } for a reply to a comment, { quote: true } for a quote post. */
35
36
  extra: () => Record<string, unknown>;
36
37
  tell: (message: string) => void;
37
38
  onPosted: (data: Posted) => void;
38
- onError: (code: string, tell: (message: string) => void) => void;
39
+ /** `permission`, with "needs-permission": the one the reader hasn't given yet. */
40
+ onError: (code: string, tell: (message: string) => void, permission?: string) => void;
39
41
  onInput?: () => void;
40
42
  };
41
43
 
@@ -158,14 +160,9 @@ export function setUpComposer(form: HTMLFormElement, ctx: ComposerContext, behav
158
160
  submit.disabled = true;
159
161
  behaviour.tell(ctx.t("composer.posting"));
160
162
  try {
161
- const response = await fetch(`${ctx.base}/comment`, {
162
- method: "POST",
163
- credentials: "same-origin",
164
- headers: { "content-type": "application/json" },
165
- body: JSON.stringify({ text, root: behaviour.root(), ...behaviour.extra() }),
166
- });
163
+ const response = await send("comment", { text, root: behaviour.root(), ...behaviour.extra() });
167
164
  const data = await response.json();
168
- if (!response.ok) return behaviour.onError(data.error, behaviour.tell);
165
+ if (!response.ok) return behaviour.onError(data.error, behaviour.tell, data.permission);
169
166
  textarea.value = "";
170
167
  updateCount();
171
168
  behaviour.onPosted(data);
@@ -179,6 +176,20 @@ export function setUpComposer(form: HTMLFormElement, ctx: ComposerContext, behav
179
176
  return { textarea, updateCount };
180
177
  }
181
178
 
179
+ /** A post's age as Bluesky shows it: 10m, 4h, 6d, then the date. */
180
+ function postAge(when: string): string {
181
+ const date = new Date(when);
182
+ const lang = document.documentElement.lang || undefined;
183
+ const minutes = Math.floor((Date.now() - date.getTime()) / 60000);
184
+ const unit = (value: number, name: string) => new Intl.NumberFormat(lang, { style: "unit", unit: name, unitDisplay: "narrow" }).format(value);
185
+ if (minutes < 1) return unit(1, "minute");
186
+ if (minutes < 60) return unit(minutes, "minute");
187
+ if (minutes < 1440) return unit(Math.floor(minutes / 60), "hour");
188
+ if (minutes < 43200) return unit(Math.floor(minutes / 1440), "day");
189
+ const thisYear = date.getFullYear() === new Date().getFullYear();
190
+ return date.toLocaleDateString(lang, { month: "short", day: "numeric", ...(!thisYear && { year: "numeric" }) });
191
+ }
192
+
182
193
  /**
183
194
  * The quote preview inside a box: the reader's words as they type, with the
184
195
  * post being quoted beneath, as Bluesky will show it. Call update() as they
@@ -202,6 +213,7 @@ export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElemen
202
213
  const quoted = (await response.json()) as {
203
214
  author: { handle: string; displayName?: string; avatar?: string };
204
215
  text: string;
216
+ indexedAt?: string;
205
217
  external?: { uri: string; title: string; description: string; thumb?: string };
206
218
  };
207
219
  loaded = uri;
@@ -209,6 +221,7 @@ export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElemen
209
221
  $<HTMLElement>("[data-quoted-name]").textContent = quotedName;
210
222
  ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
211
223
  $<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
224
+ $<HTMLElement>("[data-quoted-age]").textContent = quoted.indexedAt ? `· ${postAge(quoted.indexedAt)}` : "";
212
225
  $<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
213
226
  const card = $<HTMLElement>("[data-card]");
214
227
  card.hidden = !quoted.external;
@@ -0,0 +1,106 @@
1
+ // Subscribe / Subscribed buttons (marked data-atmosphere-subscribe): the panel
2
+ // under a post and any others a site places. Each component looks after its
3
+ // own buttons and how it offers signing in; what the reader's subscription is
4
+ // lives here, so every button on the page agrees.
5
+ //
6
+ // Signing in asks for this along with everything else, so there's never a
7
+ // second trip to the reader's account host for it.
8
+ import { canSubscribe, send, subscribed } from "./account";
9
+
10
+ type Translate = (key: string, vars?: Record<string, string | number>) => string;
11
+ /** Offers the sign-in form, coming back to #atmo-subscribe; `problem` says why it's needed again. */
12
+ type AskToSignIn = (problem?: string) => void;
13
+
14
+ const SIGN_IN_PROBLEMS: Record<string, string> = {
15
+ "sign-in-failed": "messages.signInFailed",
16
+ "account-not-found": "messages.accountNotFound",
17
+ "invalid-handle": "messages.invalidHandle",
18
+ };
19
+
20
+ const buttons = new Map<HTMLButtonElement, Translate>();
21
+ let isSubscribed = false;
22
+ let loaded: Promise<boolean | null> | undefined;
23
+
24
+ const render = () => {
25
+ for (const [button, t] of buttons) {
26
+ button.title = t(isSubscribed ? "subscribe.subscribedLabel" : "subscribe.subscribeLabel");
27
+ // A label to fill in; without one, the button keeps what the site put in
28
+ // it (and shows the state with [aria-pressed]), so it needs its full name.
29
+ const label = button.querySelector("[data-atmosphere-subscribe-label]");
30
+ if (label) label.textContent = t(isSubscribed ? "subscribe.subscribed" : "subscribe.subscribe");
31
+ else button.setAttribute("aria-label", button.title);
32
+ button.setAttribute("aria-pressed", String(isSubscribed));
33
+ button.hidden = false;
34
+ }
35
+ };
36
+
37
+ /** Whether the reader subscribes, asked once a page; null if they aren't signed in. */
38
+ export function loadSubscribed(): Promise<boolean | null> {
39
+ loaded ??= subscribed().then((found) => {
40
+ isSubscribed = !!found;
41
+ render();
42
+ return found;
43
+ });
44
+ return loaded;
45
+ }
46
+
47
+ /** After signing out here. */
48
+ export function forgetSubscribed() {
49
+ loaded = undefined;
50
+ isSubscribed = false;
51
+ render();
52
+ }
53
+
54
+ async function press(button: HTMLButtonElement, t: Translate, ask: AskToSignIn) {
55
+ for (const each of buttons.keys()) each.disabled = true;
56
+ let problem = "";
57
+ try {
58
+ const response = await send("subscribe", { on: !isSubscribed });
59
+ const data = (await response.json().catch(() => ({}))) as { subscribed?: boolean; error?: string };
60
+ if (response.ok) isSubscribed = !!data.subscribed;
61
+ else if (data.error === "signed-out") ask();
62
+ else problem = t("messages.error");
63
+ } catch {
64
+ problem = t("messages.error");
65
+ }
66
+ for (const each of buttons.keys()) each.disabled = false;
67
+ render();
68
+ // Said beside the button, as the page may be showing nothing else.
69
+ document.querySelectorAll(".atmo-subscribe-problem").forEach((part) => part.remove());
70
+ if (problem) {
71
+ const note = Object.assign(document.createElement("span"), { className: "atmo-subscribe-problem", textContent: problem });
72
+ note.setAttribute("role", "status");
73
+ button.after(note);
74
+ }
75
+ }
76
+
77
+ /** Shows these buttons, if the site has a publication to subscribe to. */
78
+ export function addSubscribeButtons(list: HTMLButtonElement[], t: Translate, ask: AskToSignIn) {
79
+ if (!canSubscribe()) return;
80
+ for (const button of list) {
81
+ buttons.set(button, t);
82
+ button.addEventListener("click", () => void press(button, t, ask));
83
+ }
84
+ render();
85
+ }
86
+
87
+ let finished = false;
88
+
89
+ /** Back from signing in after pressing Subscribe: subscribes, as they asked. */
90
+ export async function finishPendingSubscribe(t: Translate, ask: AskToSignIn) {
91
+ if (finished || location.hash !== "#atmo-subscribe") return;
92
+ finished = true;
93
+ const url = new URL(location.href);
94
+ const code = url.searchParams.get("atmosphere") ?? "";
95
+ if (!document.querySelector("atmosphere-comments")) url.searchParams.delete("atmosphere");
96
+ history.replaceState(history.state, "", `${url.pathname}${url.search}`);
97
+ const known = await loadSubscribed();
98
+ if (known === null) {
99
+ if (SIGN_IN_PROBLEMS[code]) ask(t(SIGN_IN_PROBLEMS[code]));
100
+ return;
101
+ }
102
+ const [first] = buttons.keys();
103
+ if (!first) return;
104
+ first.scrollIntoView({ block: "center" });
105
+ if (!known) await press(first, t, ask);
106
+ }
package/lib/http.ts CHANGED
@@ -24,3 +24,31 @@ export const json = (body: unknown, status = 200) =>
24
24
  export function fromThisSite(request: Request, url: URL): boolean {
25
25
  return request.headers.get("origin") === url.origin;
26
26
  }
27
+
28
+ /** A simple limit per key: at most `perMinute` a minute and `perHour` an hour. True when over it. */
29
+ export function rateLimit(perMinute: number, perHour: number) {
30
+ const recent = new Map<string, number[]>();
31
+ return (key: string): boolean => {
32
+ const now = Date.now();
33
+ const times = (recent.get(key) ?? []).filter((time) => now - time < 60 * 60 * 1000);
34
+ if (times.length) recent.set(key, times);
35
+ else recent.delete(key);
36
+ if (times.length >= perHour || times.filter((time) => now - time < 60 * 1000).length >= perMinute) return true;
37
+ times.push(now);
38
+ recent.set(key, times);
39
+ return false;
40
+ };
41
+ }
42
+
43
+ /**
44
+ * The visitor's address, for rate limits only (never stored or logged).
45
+ * Behind a proxy every visitor may share one, so routes add an overall cap.
46
+ */
47
+ export function addressOf(context: { clientAddress: string }): string {
48
+ try {
49
+ return context.clientAddress;
50
+ } catch {
51
+ // Some adapters can't tell; the overall cap still applies.
52
+ return "unknown";
53
+ }
54
+ }
@@ -0,0 +1,132 @@
1
+ // What a reader's account host is asked to allow, and the site's OAuth
2
+ // client metadata. Shared by the server (routes/client-metadata.json.ts) and
3
+ // the browser (lib/client/account.ts), so both always agree.
4
+ //
5
+ // The site is a browser ("public") client: a reader's sign-in is kept only in
6
+ // their own browser, and the site's server never sees it. Signing out cancels
7
+ // it at the reader's account host.
8
+
9
+ // What every signed-in reader grants: their identity, and reading posts as
10
+ // they see them (to know whether they've already liked or reposted),
11
+ // through Bluesky's AppView. Nothing here can change their account.
12
+ export const BASE_SCOPE = "atproto rpc:app.bsky.feed.getPosts?aud=did:web:api.bsky.app%23bsky_appview";
13
+
14
+ // Everything else comes in groups. Signing in asks for all that a reader can
15
+ // do here at once (DEFAULT_PERMISSIONS), so there's one approval rather than
16
+ // a trip to their account host for each new thing: as the sign-in stays in
17
+ // their browser, the server can't use any of it, and the page's own code has
18
+ // the same reach either way. (A permission can only name a kind of record,
19
+ // not particular posts, so lib/client/account.ts limits each one to the
20
+ // site's own threads.)
21
+ export const PERMISSIONS = {
22
+ // Comments, replies and quote posts.
23
+ post: "repo:app.bsky.feed.post?action=create",
24
+ // Likes and reposts, and taking them back.
25
+ react: "repo:app.bsky.feed.like?action=create&action=delete repo:app.bsky.feed.repost?action=create&action=delete",
26
+ // Deleting the reader's own comments.
27
+ delete: "repo:app.bsky.feed.post?action=delete",
28
+ // Following (and unfollowing) the site's own accounts.
29
+ follow: "repo:app.bsky.graph.follow?action=create&action=delete",
30
+ // For the site's own accounts: hiding comments under their announcements.
31
+ moderate: "repo:app.bsky.feed.threadgate?action=create&action=update",
32
+ // With the `standardSite` option: subscribing to the site's publication and
33
+ // recommending its posts (Standard.site's own permission set, which account
34
+ // hosts show as one line).
35
+ social: "include:site.standard.authSocial",
36
+ } as const;
37
+
38
+ export type Permission = keyof typeof PERMISSIONS;
39
+
40
+ export const isPermission = (value: unknown): value is Permission =>
41
+ typeof value === "string" && Object.hasOwn(PERMISSIONS, value);
42
+
43
+ // What signing in asks for: everything readers can do here. "moderate" is
44
+ // added only for the site's own accounts (allowedAuthors), the only ones
45
+ // that can use it, and "social" only for sites with the `standardSite` option.
46
+ // Sign-ins from before a group was added are asked for it when it's first
47
+ // needed; those from before "social" sign in again instead (account.ts).
48
+ export const DEFAULT_PERMISSIONS: Permission[] = ["post", "react", "delete", "follow"];
49
+
50
+ /** The scope to ask for: the base, plus the groups named. */
51
+ export const scopeFor = (permissions: Iterable<Permission>) =>
52
+ [BASE_SCOPE, ...new Set([...permissions].map((name) => PERMISSIONS[name]))].join(" ");
53
+
54
+ // Everything the site may ask for, which its client metadata has to list.
55
+ export const allScopes = (settings: { standardSite?: unknown }) =>
56
+ scopeFor((Object.keys(PERMISSIONS) as Permission[]).filter((name) => name !== "social" || !!settings.standardSite));
57
+
58
+ // What Standard.site's permission set covers (its lexicon lists both).
59
+ const SOCIAL_COLLECTIONS = ["site.standard.graph.subscription", "site.standard.graph.recommend"];
60
+
61
+ /**
62
+ * Whether `scopes` (a grant) allow `action` on records of `collection`: a
63
+ * repo permission for that collection (or all of them) listing the action,
64
+ * or listing no actions, which means all of them. A host may keep the
65
+ * permission set's name in the grant, or write it out as repo permissions.
66
+ */
67
+ export function scopesAllow(scopes: string[], collection: string, action: string): boolean {
68
+ return scopes.some((scope) => {
69
+ const [name, query = ""] = scope.split("?");
70
+ if (name === PERMISSIONS.social) return SOCIAL_COLLECTIONS.includes(collection);
71
+ const params = new URLSearchParams(query);
72
+ const listed = name === "repo" && params.getAll("collection").some((item) => item === collection || item === "*");
73
+ if (name !== `repo:${collection}` && name !== "repo:*" && !listed) return false;
74
+ const actions = params.getAll("action");
75
+ return !actions.length || actions.includes(action);
76
+ });
77
+ }
78
+
79
+ /** The permission groups a grant covers in full. */
80
+ export function grantedPermissions(scopes: string[]): Permission[] {
81
+ return (Object.keys(PERMISSIONS) as Permission[]).filter((name) =>
82
+ PERMISSIONS[name].split(" ").every((part) => {
83
+ if (part === PERMISSIONS.social) {
84
+ return SOCIAL_COLLECTIONS.every((collection) => ["create", "delete"].every((action) => scopesAllow(scopes, collection, action)));
85
+ }
86
+ const [scope, query = ""] = part.split("?");
87
+ const collection = scope.replace(/^repo:/, "");
88
+ return new URLSearchParams(query).getAll("action").every((action) => scopesAllow(scopes, collection, action));
89
+ }),
90
+ );
91
+ }
92
+
93
+ /** Whether a grant has BASE_SCOPE (compared by name, as hosts may write the rest differently). */
94
+ export function hasBase(scopes: string[]): boolean {
95
+ const names = scopes.map((part) => part.split("?")[0]);
96
+ return BASE_SCOPE.split(" ").every((part) => names.includes(part.split("?")[0]));
97
+ }
98
+
99
+ type MetadataSettings = {
100
+ site: string;
101
+ base: string;
102
+ clientName: string;
103
+ logoUri?: string;
104
+ policyUri?: string;
105
+ tosUri?: string;
106
+ standardSite?: { publication: string; feed?: string };
107
+ };
108
+
109
+ export const callbackPath = (base: string) => `${base}/callback`;
110
+
111
+ /** The client metadata, served at /oauth-client-metadata.json (the site's client ID). */
112
+ export function clientMetadata(settings: MetadataSettings) {
113
+ return {
114
+ // At the site's root, the address account hosts treat as the convention:
115
+ // Bluesky's approval page then shows just the site's address.
116
+ client_id: `${settings.site}/oauth-client-metadata.json`,
117
+ client_name: settings.clientName,
118
+ client_uri: settings.site,
119
+ ...(settings.logoUri && { logo_uri: settings.logoUri }),
120
+ ...(settings.policyUri && { policy_uri: settings.policyUri }),
121
+ ...(settings.tosUri && { tos_uri: settings.tosUri }),
122
+ redirect_uris: [`${settings.site}${callbackPath(settings.base)}`] as [`https://${string}`],
123
+ scope: allScopes(settings),
124
+ grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"],
125
+ response_types: ["code"] as ["code"],
126
+ application_type: "web" as const,
127
+ // A browser client: it has no secret to prove who it is, and its
128
+ // tokens only work together with a key kept in the reader's browser.
129
+ token_endpoint_auth_method: "none" as const,
130
+ dpop_bound_access_tokens: true,
131
+ };
132
+ }
@@ -0,0 +1,20 @@
1
+ // The integration's settings that are safe for readers' browsers: the
2
+ // site's address, the routes' base, its name and links, and its accounts.
3
+ // (The rest, such as `hidden`, stays on the server: lib/settings.ts.)
4
+ import settings from "virtual:atmosphere-comments/public-settings";
5
+
6
+ export type PublicSettings = {
7
+ clientName: string;
8
+ allowedAuthors: string[];
9
+ /** Where the routes live, e.g. "/atmosphere" (no trailing slash). */
10
+ base: string;
11
+ /** The site's address, e.g. "https://saltedlolly.com" (no trailing slash). */
12
+ site: string;
13
+ logoUri?: string;
14
+ policyUri?: string;
15
+ tosUri?: string;
16
+ /** The site's Standard.site publication: turns on Subscribe, and recommending with Like. */
17
+ standardSite?: { publication: string; feed?: string };
18
+ };
19
+
20
+ export default settings as PublicSettings;
package/lib/quoted.ts CHANGED
@@ -5,6 +5,8 @@ import { profileFor, type Profile } from "./profile";
5
5
  export type Quoted = {
6
6
  author: Profile;
7
7
  text: string;
8
+ /** When Bluesky received the post (for a backdated announcement, not its stated date). */
9
+ indexedAt?: string;
8
10
  external?: { uri: string; title: string; description: string; thumbSource?: string };
9
11
  };
10
12
 
@@ -22,6 +24,7 @@ export async function quotedPost(uri: string): Promise<Quoted | null> {
22
24
  const [post] = ((await response.json()) as {
23
25
  posts?: {
24
26
  author: { did: string };
27
+ indexedAt?: string;
25
28
  record: { text?: string };
26
29
  embed?: { external?: { uri: string; title: string; description: string; thumb?: string } };
27
30
  }[];
@@ -31,6 +34,7 @@ export async function quotedPost(uri: string): Promise<Quoted | null> {
31
34
  const quoted: Quoted = {
32
35
  author: await profileFor(post.author.did),
33
36
  text: post.record.text ?? "",
37
+ indexedAt: post.indexedAt,
34
38
  ...(external && {
35
39
  external: {
36
40
  uri: external.uri,
package/lib/settings.ts CHANGED
@@ -12,6 +12,8 @@ export type AtmosphereSettings = {
12
12
  logoUri?: string;
13
13
  policyUri?: string;
14
14
  tosUri?: string;
15
+ /** The site's Standard.site publication: turns on Subscribe, and recommending with Like. */
16
+ standardSite?: { publication: string; feed?: string };
15
17
  hidden?: string[];
16
18
  locale?: string;
17
19
  messages?: Record<string, string>;
package/lib/thread.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  // The Bluesky thread under an announcement, read from Bluesky's public API:
2
2
  // who liked and reposted it, and the replies (the comments), nested.
3
3
  //
4
- // Left out: replies the announcement's author hid (Bluesky's threadgate),
5
- // deleted or blocked posts, posts or accounts carrying hiding labels from
6
- // Bluesky's moderation (spam, porn…), and anything in the `hidden` option.
4
+ // Left out, with the replies under them: replies the announcement's author
5
+ // hid (Bluesky's threadgate), deleted or blocked posts, posts or accounts
6
+ // carrying hiding labels from Bluesky's moderation (spam, porn…), and
7
+ // anything in the `hidden` option.
8
+ //
9
+ // Accounts that ask apps not to show them to logged-out people (Bluesky's
10
+ // "Discourage apps from showing my account to logged-out users", the
11
+ // !no-unauthenticated label) are respected too, as the page is public:
12
+ // their comments become a bare placeholder (no name, picture, address or
13
+ // words), keeping the replies under them, which are other people's, and
14
+ // they're left out of the faces of who liked and reposted.
7
15
  // Cached for two minutes per announcement; if Bluesky can't be reached the
8
16
  // last good copy is kept.
9
17
  import { rememberAvatar } from "./avatars";
@@ -17,6 +25,7 @@ const MAX_CACHED = 200;
17
25
  const MAX_DEPTH = 6;
18
26
  const MAX_FACES = 48;
19
27
  const HIDING_LABELS = new Set(["!hide", "!takedown", "!suspend", "spam", "porn", "sexual", "nudity", "graphic-media", "gore"]);
28
+ const LOGGED_IN_ONLY = "!no-unauthenticated";
20
29
 
21
30
  export type Person = { did: string; handle: string; displayName?: string; avatar?: string; url: string };
22
31
 
@@ -32,6 +41,11 @@ export type Reply = {
32
41
  depth: number;
33
42
  likeCount: number;
34
43
  repostCount: number;
44
+ /**
45
+ * Its author asks not to be shown to logged-out people: everything else
46
+ * is left empty, and the page shows a placeholder in its place.
47
+ */
48
+ withheld?: true;
35
49
  };
36
50
 
37
51
  export type Thread = {
@@ -80,6 +94,9 @@ function person(author: Author): Person | null {
80
94
  };
81
95
  }
82
96
 
97
+ /** Whether an account asks apps not to show it to people who aren't signed in. */
98
+ const loggedInOnly = (author?: Author) => !!author?.labels?.some((label) => label.val === LOGGED_IN_ONLY);
99
+
83
100
  function isHidden(post: ThreadPost, author: Person, url: string): boolean {
84
101
  const labels = [...(post.labels ?? []), ...(post.author?.labels ?? [])];
85
102
  if (labels.some((label) => label.val && HIDING_LABELS.has(label.val))) return true;
@@ -97,7 +114,20 @@ function collect(nodes: ThreadNode[] = [], depth: number, hiddenByAuthor: Set<st
97
114
  if (!author || !post.uri || !key || hiddenByAuthor.has(post.uri)) continue;
98
115
  const url = `${author.url}/post/${key}`;
99
116
  if (isHidden(post, author, url)) continue;
100
- out.push({
117
+ if (loggedInOnly(post.author)) {
118
+ out.push({
119
+ uri: "",
120
+ url: "",
121
+ author: { did: "", handle: "", url: "" },
122
+ byAuthor: false,
123
+ createdAt: "",
124
+ text: [],
125
+ depth,
126
+ likeCount: 0,
127
+ repostCount: 0,
128
+ withheld: true,
129
+ });
130
+ } else out.push({
101
131
  uri: post.uri,
102
132
  url,
103
133
  author,
@@ -131,8 +161,8 @@ async function fetchThread(uri: string): Promise<Thread> {
131
161
  url: rootAuthor ? `${rootAuthor.url}/post/${uri.split("/").pop()}` : "",
132
162
  likeCount: root.likeCount ?? likes.length,
133
163
  repostCount: root.repostCount ?? repostedBy.length,
134
- likes: likes.flatMap(({ actor }) => (actor ? [person(actor)].filter((p): p is Person => !!p) : [])),
135
- reposts: repostedBy.map(person).filter((p): p is Person => !!p),
164
+ likes: likes.flatMap(({ actor }) => (actor && !loggedInOnly(actor) ? [person(actor)].filter((p): p is Person => !!p) : [])),
165
+ reposts: repostedBy.filter((actor) => !loggedInOnly(actor)).map(person).filter((p): p is Person => !!p),
136
166
  replies,
137
167
  };
138
168
  }
package/lib/uris.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Telling the site's own posts and threads apart from everything else, for
2
+ // the routes and the browser alike. Nothing outside these can be replied to,
3
+ // liked, reposted or quoted through the site.
4
+ import settings from "./public-settings";
5
+
6
+ const POST_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._~-]+)$/;
7
+
8
+ export const isPostUri = (uri: unknown): uri is string => typeof uri === "string" && POST_URI.test(uri);
9
+
10
+ /** The DID that wrote a post, from its AT URI. */
11
+ export const authorOf = (uri: string) => POST_URI.exec(uri)?.[1] ?? "";
12
+
13
+ /** The post's record key, the last part of its AT URI. */
14
+ export const rkeyOf = (uri: string) => POST_URI.exec(uri)?.[2] ?? "";
15
+
16
+ /** True for a post by one of the site's own accounts (an announcement). */
17
+ export function isSitePost(uri: unknown): uri is string {
18
+ return isPostUri(uri) && settings.allowedAuthors.includes(authorOf(uri));
19
+ }
20
+
21
+ const DOCUMENT_URI = /^at:\/\/(did:[a-z0-9]+:[a-zA-Z0-9._:%-]+)\/site\.standard\.document\/[a-zA-Z0-9._~-]+$/;
22
+
23
+ /** True for a Standard.site document in the same account as the site's publication. */
24
+ export function isSiteDocument(uri: unknown): uri is string {
25
+ const publisher = settings.standardSite?.publication.split("/")[2];
26
+ return typeof uri === "string" && !!publisher && DOCUMENT_URI.exec(uri)?.[1] === publisher;
27
+ }
28
+
29
+ export type PostView = {
30
+ uri: string;
31
+ cid: string;
32
+ author?: { did: string };
33
+ record?: { reply?: { root?: { uri?: string } } };
34
+ viewer?: { like?: string; repost?: string };
35
+ };
36
+
37
+ /** True for one of the site's announcements, or a comment somewhere under one. */
38
+ export const inSiteThread = (view: PostView): boolean =>
39
+ isSitePost(view.uri) || isSitePost(view.record?.reply?.root?.uri);
package/locales/de.json CHANGED
@@ -49,7 +49,7 @@
49
49
  "comment.deleteConfirm": "Diesen Kommentar von Bluesky löschen?",
50
50
  "comment.deleting": "Wird gelöscht…",
51
51
  "comment.deleted": "Dein Kommentar wurde gelöscht.",
52
- "comment.deletePermission": "Um hier Kommentare zu löschen, braucht diese Seite eine weitere Erlaubnis von deinem Konto. Du kannst ihn auch in jeder Bluesky-App löschen.",
52
+ "comment.deletePermission": "Um hier Kommentare zu löschen, braucht diese Seite eine weitere Erlaubnis von deinem Atmosphere-Konto. Du kannst ihn aber auch {{link}} löschen.",
53
53
  "comment.continue": "Weiter zur Bestätigung",
54
54
  "comment.cancel": "Abbrechen",
55
55
  "signInFirst.reply": "Melde dich an, um zu antworten.",
@@ -65,9 +65,8 @@
65
65
  "messages.accountNotFound": "Dieses Konto wurde nicht gefunden. Prüfe den Handle und versuche es noch einmal.",
66
66
  "messages.invalidHandle": "Das sieht nicht wie ein Handle aus. Meist ist es etwas wie alice.bsky.social.",
67
67
  "messages.signedOut": "Du wurdest abgemeldet. Bitte melde dich erneut an.",
68
- "messages.needsPermission": "Um hier Kommentare zu löschen, braucht diese Seite eine weitere Erlaubnis von deinem Konto.",
68
+ "messages.needsPermission": "Dafür braucht es eine weitere Erlaubnis von deinem Konto.",
69
69
  "messages.notAllowed": "Das ist hier nicht möglich.",
70
- "messages.newPermissions": "Diese Seite kann jetzt auch für dich liken und reposten und braucht deshalb erneut deine Erlaubnis. Bitte melde dich erneut an.",
71
70
  "messages.tooLong": "Beiträge können bis zu {{max}} Zeichen lang sein.",
72
71
  "messages.tooMany": "Du bist ziemlich schnell. Bitte warte eine Minute und versuche es noch einmal.",
73
72
  "messages.error": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
@@ -91,5 +90,22 @@
91
90
  "follow.following": "Folge ich",
92
91
  "follow.followAs": "{{handle}} als {{me}} folgen",
93
92
  "follow.followingLabel": "Du folgst {{handle}}",
94
- "credit": "Bereitgestellt von {{link}}"
93
+ "credit": "Bereitgestellt von {{link}}",
94
+ "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
+ "signIn.policy": "Wie diese Seite mit deinen Daten umgeht",
97
+ "composer.needsPermission": "Um hier als {{handle}} zu kommentieren, braucht diese Seite eine weitere Erlaubnis von deinem Atmosphere-Konto.",
98
+ "comment.hide": "Ausblenden",
99
+ "comment.hideLabel": "Diesen Kommentar ausblenden",
100
+ "comment.hideConfirm": "Diesen Kommentar hier und auf Bluesky ausblenden? In der Bluesky-App kannst du ihn wieder einblenden.",
101
+ "comment.hiding": "Wird ausgeblendet …",
102
+ "comment.hidden": "Kommentar ausgeblendet.",
103
+ "comment.hidePermission": "Um hier Kommentare auszublenden, braucht diese Seite eine weitere Erlaubnis von deinem Konto. Du kannst sie auch in der Bluesky-App ausblenden.",
104
+ "comment.deleteElsewhere": "auf der Bluesky-Website",
105
+ "permission.heading": "Eine weitere Erlaubnis",
106
+ "permission.follow": "Um {{handle}} von dieser Seite aus zu folgen, braucht sie eine weitere Erlaubnis von deinem Atmosphere-Konto: Konten folgen und entfolgen.",
107
+ "permission.post": "Um von dieser Seite aus zu antworten und zu zitieren, braucht sie eine weitere Erlaubnis von deinem Atmosphere-Konto: Beiträge erstellen.",
108
+ "permission.react": "Um von dieser Seite aus zu liken und zu reposten, braucht sie eine weitere Erlaubnis von deinem Atmosphere-Konto: liken und reposten und das zurücknehmen.",
109
+ "permission.approve": "Die Seite deines Kontos (für die meisten die von Bluesky) bittet dich, das zu bestätigen, und vielleicht vorher, dich dort anzumelden. Danach kommst du direkt hierher zurück.",
110
+ "comment.withheld": "Hier nicht angezeigt: Die Person bittet Apps, ihre Beiträge nicht Leuten zu zeigen, die nicht angemeldet sind."
95
111
  }