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.
- package/CHANGELOG.md +40 -0
- package/README.md +36 -27
- package/components/AtmosphereComments.astro +378 -117
- package/components/AtmosphereReactions.astro +145 -98
- package/components/Composer.astro +5 -2
- package/components/SignInForm.astro +65 -7
- package/components/atmosphere.css +267 -2
- package/index.d.ts +11 -0
- package/index.mjs +53 -32
- package/lib/client/account.ts +661 -0
- package/lib/client/composer.ts +23 -10
- package/lib/client/subscribe.ts +106 -0
- package/lib/http.ts +28 -0
- package/lib/permissions.ts +132 -0
- package/lib/public-settings.ts +20 -0
- package/lib/quoted.ts +4 -0
- package/lib/settings.ts +2 -0
- package/lib/thread.ts +36 -6
- package/lib/uris.ts +39 -0
- package/locales/de.json +20 -4
- package/locales/en.json +48 -13
- package/locales/es.json +20 -4
- package/locales/fr.json +20 -4
- package/locales/it.json +20 -4
- package/locales/ja.json +20 -4
- package/locales/nl.json +20 -4
- package/locales/pt.json +20 -4
- package/package.json +2 -7
- package/routes/callback.astro +42 -0
- package/routes/client-metadata.json.ts +3 -2
- package/routes/following.ts +28 -0
- package/routes/identity.ts +69 -0
- package/routes/media.ts +7 -10
- package/routes/post.ts +2 -1
- package/routes/profile.ts +20 -0
- package/routes/richtext.ts +30 -0
- package/routes/thread.ts +23 -3
- package/routes/typeahead.ts +7 -23
- package/virtual.d.ts +5 -0
- package/bin/cli.mjs +0 -31
- package/lib/oauth.ts +0 -110
- package/lib/reader.ts +0 -130
- package/lib/store.ts +0 -46
- package/lib/visitor.ts +0 -42
- package/routes/callback.ts +0 -30
- package/routes/comment.ts +0 -97
- package/routes/delete.ts +0 -41
- package/routes/follow.ts +0 -71
- package/routes/jwks.json.ts +0 -13
- package/routes/login.ts +0 -58
- package/routes/logout.ts +0 -20
- package/routes/me.ts +0 -12
- package/routes/react.ts +0 -48
- package/routes/state.ts +0 -24
package/lib/client/composer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
5
|
-
// deleted or blocked posts, posts or accounts
|
|
6
|
-
// Bluesky's moderation (spam, porn…), and
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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
|
}
|