astro-atmosphere-comments 0.2.0 → 0.4.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.
@@ -0,0 +1,232 @@
1
+ // The comment box's behaviour: counting characters, @mention suggestions,
2
+ // posting (as a reply, or a quote post), and the quote preview. Used by
3
+ // AtmosphereComments (its main box and every reply box) and by the reply and
4
+ // quote overlay of AtmosphereReactions. The markup is Composer.astro.
5
+
6
+ export const MAX = 300; // Bluesky's limit for a post, in characters as people count them
7
+ const graphemes = new Intl.Segmenter();
8
+ export const count = (text: string) => [...graphemes.segment(text.trim())].length;
9
+
10
+ export type Suggestion = { handle: string; displayName?: string; avatar?: string };
11
+ export type Person = { did: string; handle: string; displayName?: string; avatar?: string };
12
+ /** What the comment route answers with once something is posted. */
13
+ export type Posted = {
14
+ uri: string;
15
+ url: string;
16
+ quote: boolean;
17
+ text: string;
18
+ createdAt: string;
19
+ parent: string | null;
20
+ author: Person;
21
+ };
22
+
23
+ export type ComposerContext = {
24
+ base: string;
25
+ t: (key: string, vars?: Record<string, string | number>) => string;
26
+ /** The wording for an error code from the routes. */
27
+ message: (code: string) => string;
28
+ setAvatar: (slot: HTMLElement, name: string, src?: string) => void;
29
+ };
30
+
31
+ export type ComposerBehaviour = {
32
+ /** The post being replied to or quoted (the announcement, or another post by the site's accounts). */
33
+ root: () => string;
34
+ /** More for the comment route: { parent } for a reply to a comment, { quote: true } for a quote post. */
35
+ extra: () => Record<string, unknown>;
36
+ tell: (message: string) => void;
37
+ onPosted: (data: Posted) => void;
38
+ onError: (code: string, tell: (message: string) => void) => void;
39
+ onInput?: () => void;
40
+ };
41
+
42
+ export function setUpComposer(form: HTMLFormElement, ctx: ComposerContext, behaviour: ComposerBehaviour) {
43
+ const textarea = form.querySelector("textarea")!;
44
+ const counter = form.querySelector<HTMLElement>("[data-count]")!;
45
+ const submit = form.querySelector<HTMLButtonElement>("[data-submit]")!;
46
+ const suggestions = form.querySelector<HTMLUListElement>("[data-suggestions]")!;
47
+
48
+ const updateCount = () => {
49
+ const used = count(textarea.value);
50
+ counter.textContent = used > MAX - 50 ? ctx.t("composer.charactersLeft", { count: MAX - used }) : "";
51
+ counter.classList.toggle("is-over", used > MAX);
52
+ };
53
+
54
+ // @mention suggestions, as in the Bluesky app: typing @ and the start
55
+ // of a handle or name lists matching accounts to choose from.
56
+ let options: Suggestion[] = [];
57
+ let active = -1;
58
+ let lookup: number | undefined;
59
+ let latestQuery = "";
60
+ const mentionAtCaret = () => {
61
+ const before = textarea.value.slice(0, textarea.selectionStart);
62
+ const match = /(?:^|[\s(])@([a-zA-Z0-9.-]{1,64})$/.exec(before);
63
+ return match ? { query: match[1], start: before.length - match[1].length - 1 } : null;
64
+ };
65
+ const closeSuggestions = () => {
66
+ suggestions.hidden = true;
67
+ suggestions.replaceChildren();
68
+ options = [];
69
+ active = -1;
70
+ textarea.setAttribute("aria-expanded", "false");
71
+ textarea.removeAttribute("aria-activedescendant");
72
+ };
73
+ const highlight = (index: number) => {
74
+ active = index;
75
+ suggestions.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
76
+ if (index >= 0) textarea.setAttribute("aria-activedescendant", `${suggestions.id}-option-${index}`);
77
+ };
78
+ const choose = (index: number) => {
79
+ const mention = mentionAtCaret();
80
+ const option = options[index];
81
+ if (!mention || !option) return closeSuggestions();
82
+ const after = textarea.value.slice(textarea.selectionStart);
83
+ textarea.value = `${textarea.value.slice(0, mention.start)}@${option.handle} ${after}`;
84
+ const caret = mention.start + option.handle.length + 2;
85
+ textarea.setSelectionRange(caret, caret);
86
+ closeSuggestions();
87
+ textarea.focus();
88
+ updateCount();
89
+ behaviour.onInput?.();
90
+ };
91
+ const showSuggestions = (found: Suggestion[]) => {
92
+ options = found;
93
+ if (!found.length) return closeSuggestions();
94
+ suggestions.replaceChildren(
95
+ ...found.map((option, index) => {
96
+ const item = document.createElement("li");
97
+ item.id = `${suggestions.id}-option-${index}`;
98
+ item.setAttribute("role", "option");
99
+ const avatar = document.createElement("span");
100
+ avatar.className = "atmo__avatar atmo__avatar--small";
101
+ ctx.setAvatar(avatar, option.displayName || option.handle, option.avatar);
102
+ const name = Object.assign(document.createElement("strong"), { textContent: option.displayName || option.handle });
103
+ const handle = Object.assign(document.createElement("span"), { textContent: `@${option.handle}` });
104
+ item.append(avatar, name, handle);
105
+ // mousedown keeps the focus in the text box.
106
+ item.addEventListener("mousedown", (event) => event.preventDefault());
107
+ item.addEventListener("click", () => choose(index));
108
+ return item;
109
+ }),
110
+ );
111
+ suggestions.hidden = false;
112
+ textarea.setAttribute("aria-expanded", "true");
113
+ highlight(0);
114
+ };
115
+ const lookUpMention = () => {
116
+ window.clearTimeout(lookup);
117
+ const mention = mentionAtCaret();
118
+ if (!mention) return closeSuggestions();
119
+ latestQuery = mention.query;
120
+ lookup = window.setTimeout(async () => {
121
+ const query = mention.query;
122
+ try {
123
+ const response = await fetch(`${ctx.base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
124
+ const data = (await response.json()) as { actors?: Suggestion[] };
125
+ if (query === latestQuery) showSuggestions(data.actors ?? []);
126
+ } catch {
127
+ closeSuggestions();
128
+ }
129
+ }, 200);
130
+ };
131
+
132
+ textarea.addEventListener("input", () => {
133
+ updateCount();
134
+ behaviour.onInput?.();
135
+ lookUpMention();
136
+ });
137
+ textarea.addEventListener("keydown", (event) => {
138
+ if (suggestions.hidden) return;
139
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
140
+ event.preventDefault();
141
+ const step = event.key === "ArrowDown" ? 1 : -1;
142
+ highlight((active + step + options.length) % options.length);
143
+ } else if (event.key === "Enter" || event.key === "Tab") {
144
+ event.preventDefault();
145
+ choose(Math.max(active, 0));
146
+ } else if (event.key === "Escape") {
147
+ event.preventDefault();
148
+ closeSuggestions();
149
+ }
150
+ });
151
+ textarea.addEventListener("blur", () => window.setTimeout(closeSuggestions, 150));
152
+
153
+ form.addEventListener("submit", async (event) => {
154
+ event.preventDefault();
155
+ const text = textarea.value.trim();
156
+ if (!text) return;
157
+ if (count(text) > MAX) return behaviour.tell(ctx.message("too-long"));
158
+ submit.disabled = true;
159
+ behaviour.tell(ctx.t("composer.posting"));
160
+ 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
+ });
167
+ const data = await response.json();
168
+ if (!response.ok) return behaviour.onError(data.error, behaviour.tell);
169
+ textarea.value = "";
170
+ updateCount();
171
+ behaviour.onPosted(data);
172
+ } catch {
173
+ behaviour.tell(ctx.message("error"));
174
+ } finally {
175
+ submit.disabled = false;
176
+ }
177
+ });
178
+
179
+ return { textarea, updateCount };
180
+ }
181
+
182
+ /**
183
+ * The quote preview inside a box: the reader's words as they type, with the
184
+ * post being quoted beneath, as Bluesky will show it. Call update() as they
185
+ * type and load(uri) when quoting starts.
186
+ */
187
+ export function quotePreview(form: HTMLFormElement, textarea: HTMLTextAreaElement, ctx: ComposerContext) {
188
+ const $ = <T extends Element>(selector: string) => form.querySelector<T>(selector)!;
189
+ const previewText = $<HTMLElement>("[data-preview-text]");
190
+ let loaded = "";
191
+ const update = () => {
192
+ const words = textarea.value.trim();
193
+ previewText.textContent = words || ctx.t("composer.previewEmpty");
194
+ previewText.classList.toggle("is-placeholder", !words);
195
+ };
196
+ const load = async (uri: string) => {
197
+ update();
198
+ if (loaded === uri) return;
199
+ try {
200
+ const response = await fetch(`${ctx.base}/post?uri=${encodeURIComponent(uri)}`);
201
+ if (!response.ok) return;
202
+ const quoted = (await response.json()) as {
203
+ author: { handle: string; displayName?: string; avatar?: string };
204
+ text: string;
205
+ external?: { uri: string; title: string; description: string; thumb?: string };
206
+ };
207
+ loaded = uri;
208
+ const quotedName = quoted.author.displayName || quoted.author.handle;
209
+ $<HTMLElement>("[data-quoted-name]").textContent = quotedName;
210
+ ctx.setAvatar($<HTMLElement>("[data-quoted-avatar]"), quotedName, quoted.author.avatar);
211
+ $<HTMLElement>("[data-quoted-handle]").textContent = `@${quoted.author.handle}`;
212
+ $<HTMLElement>("[data-quoted-text]").textContent = quoted.text;
213
+ const card = $<HTMLElement>("[data-card]");
214
+ card.hidden = !quoted.external;
215
+ if (quoted.external) {
216
+ $<HTMLElement>("[data-card-title]").textContent = quoted.external.title;
217
+ $<HTMLElement>("[data-card-description]").textContent = quoted.external.description;
218
+ $<HTMLElement>("[data-card-domain]").textContent = new URL(quoted.external.uri).hostname;
219
+ const img = $<HTMLImageElement>("[data-card-image]");
220
+ img.hidden = true;
221
+ if (quoted.external.thumb) {
222
+ img.onload = () => (img.hidden = false);
223
+ img.onerror = () => (img.hidden = true);
224
+ img.src = quoted.external.thumb;
225
+ }
226
+ }
227
+ } catch {
228
+ // The preview is a nicety: quoting still works without it.
229
+ }
230
+ };
231
+ return { update, load };
232
+ }
@@ -0,0 +1,112 @@
1
+ // Matching accounts under a sign-in form's handle box as the reader types,
2
+ // with their avatars, so they can see they've found the right one. Picking one
3
+ // signs in with it. Without JavaScript the box works as a plain text box.
4
+ // Used by AtmosphereComments and AtmosphereReactions (see SignInForm.astro).
5
+
6
+ type Suggestion = { handle: string; displayName?: string; avatar?: string };
7
+
8
+ const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className = "", text?: string) => {
9
+ const element = document.createElement(tag);
10
+ if (className) element.className = className;
11
+ if (text !== undefined) element.textContent = text;
12
+ return element;
13
+ };
14
+
15
+ // An avatar picture, or the name's first letter if there isn't one.
16
+ const setAvatar = (slot: HTMLElement, name: string, src?: string) => {
17
+ slot.textContent = name.trim().charAt(0).toUpperCase();
18
+ if (!src) return;
19
+ const img = Object.assign(document.createElement("img"), { src, alt: "" });
20
+ img.onload = () => slot.replaceChildren(img);
21
+ };
22
+
23
+ /** Adds the suggestions to a SignInForm's handle box. `base` is where the integration's routes live. */
24
+ export function enhanceHandleBox(form: HTMLFormElement, base: string) {
25
+ const input = form.querySelector<HTMLInputElement>('input[name="handle"]')!;
26
+ const list = form.querySelector<HTMLUListElement>("[data-handle-suggestions]")!;
27
+ let found: Suggestion[] = [];
28
+ let active = -1;
29
+ let latest = "";
30
+ let timer: number | undefined;
31
+ input.setAttribute("role", "combobox");
32
+ input.setAttribute("aria-autocomplete", "list");
33
+ input.setAttribute("aria-controls", list.id);
34
+ input.setAttribute("aria-expanded", "false");
35
+
36
+ const close = () => {
37
+ list.hidden = true;
38
+ list.replaceChildren();
39
+ found = [];
40
+ active = -1;
41
+ input.setAttribute("aria-expanded", "false");
42
+ input.removeAttribute("aria-activedescendant");
43
+ };
44
+ const highlight = (index: number) => {
45
+ active = index;
46
+ list.querySelectorAll("li").forEach((item, i) => item.setAttribute("aria-selected", String(i === index)));
47
+ if (index >= 0) input.setAttribute("aria-activedescendant", `${list.id}-option-${index}`);
48
+ else input.removeAttribute("aria-activedescendant");
49
+ };
50
+ const choose = (index: number) => {
51
+ const option = found[index];
52
+ if (!option) return;
53
+ input.value = option.handle;
54
+ close();
55
+ form.requestSubmit();
56
+ };
57
+ const show = (actors: Suggestion[]) => {
58
+ found = actors.slice(0, 5);
59
+ if (!found.length || document.activeElement !== input) return close();
60
+ list.replaceChildren(
61
+ ...found.map((option, index) => {
62
+ const item = make("li");
63
+ item.id = `${list.id}-option-${index}`;
64
+ item.setAttribute("role", "option");
65
+ const avatar = make("span", "atmo__avatar atmo__avatar--small");
66
+ avatar.setAttribute("aria-hidden", "true");
67
+ setAvatar(avatar, option.displayName || option.handle, option.avatar);
68
+ item.append(avatar, make("strong", "", option.displayName || option.handle), make("span", "", `@${option.handle}`));
69
+ // mousedown keeps the focus in the handle box.
70
+ item.addEventListener("mousedown", (event) => event.preventDefault());
71
+ item.addEventListener("click", () => choose(index));
72
+ return item;
73
+ }),
74
+ );
75
+ list.hidden = false;
76
+ input.setAttribute("aria-expanded", "true");
77
+ // Nothing is picked until the reader chooses, so Enter still signs in
78
+ // with exactly what they typed.
79
+ highlight(-1);
80
+ };
81
+
82
+ input.addEventListener("input", () => {
83
+ window.clearTimeout(timer);
84
+ const query = input.value.trim().replace(/^@/, "");
85
+ latest = query;
86
+ if (query.length < 2 || query.startsWith("did:") || /\s/.test(query)) return close();
87
+ timer = window.setTimeout(async () => {
88
+ try {
89
+ const response = await fetch(`${base}/typeahead?q=${encodeURIComponent(query)}`, { credentials: "same-origin" });
90
+ const data = (await response.json()) as { actors?: Suggestion[] };
91
+ if (query === latest) show(data.actors ?? []);
92
+ } catch {
93
+ close();
94
+ }
95
+ }, 250);
96
+ });
97
+ input.addEventListener("keydown", (event) => {
98
+ if (list.hidden) return;
99
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
100
+ event.preventDefault();
101
+ const step = event.key === "ArrowDown" ? 1 : -1;
102
+ highlight(active < 0 && step < 0 ? found.length - 1 : (active + step + found.length) % found.length);
103
+ } else if (event.key === "Enter" && active >= 0) {
104
+ event.preventDefault();
105
+ choose(active);
106
+ } else if (event.key === "Escape") {
107
+ event.preventDefault();
108
+ close();
109
+ }
110
+ });
111
+ input.addEventListener("blur", () => window.setTimeout(close, 150));
112
+ }
package/lib/i18n.ts ADDED
@@ -0,0 +1,32 @@
1
+ // The component's wording in each language. The files in locales/ are
2
+ // i18next JSON (v4): flat keys, {{placeholders}}, and plural forms as
3
+ // key_one, key_other (and key_few, key_many… where a language needs them).
4
+ // English is the source; translations are made in Weblate, which commits them
5
+ // back to the repository. Any key a language lacks falls back to English.
6
+
7
+ import type { Messages } from "./translate";
8
+
9
+ export type { Messages } from "./translate";
10
+ export { translate, translateHtml } from "./translate";
11
+
12
+ const files = import.meta.glob<Messages>("../locales/*.json", { eager: true, import: "default" });
13
+ const LOCALES: Record<string, Messages> = Object.fromEntries(
14
+ Object.entries(files).map(([path, messages]) => [path.replace(/^.*\/|\.json$/g, ""), messages]),
15
+ );
16
+
17
+ /** The best language we have for `wanted` ("pt-BR" → "pt-BR", else "pt", else "en"). */
18
+ export function pickLocale(...wanted: Array<string | undefined>): string {
19
+ for (const tag of wanted) {
20
+ if (!tag) continue;
21
+ const exact = Object.keys(LOCALES).find((code) => code.toLowerCase() === tag.toLowerCase());
22
+ if (exact) return exact;
23
+ const base = tag.split("-")[0].toLowerCase();
24
+ if (LOCALES[base]) return base;
25
+ }
26
+ return "en";
27
+ }
28
+
29
+ /** English, with the language's own wording on top, then the site's overrides. */
30
+ export function messagesFor(locale: string, overrides: Messages = {}): Messages {
31
+ return { ...LOCALES.en, ...(LOCALES[locale] ?? {}), ...overrides };
32
+ }
package/lib/oauth.ts CHANGED
@@ -36,8 +36,12 @@ export const SCOPE = [
36
36
  // is only used on the reader's own comments under the site's posts.)
37
37
  export const DELETE_SCOPE = "repo:app.bsky.feed.post?action=delete";
38
38
 
39
+ // Following (and unfollowing) the site's own accounts is asked for the same
40
+ // way, the first time a reader presses Follow.
41
+ export const FOLLOW_SCOPE = "repo:app.bsky.graph.follow?action=create&action=delete";
42
+
39
43
  // Everything the site may ask for, which its client metadata has to list.
40
- const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE}`;
44
+ const ALL_SCOPES = `${SCOPE} ${DELETE_SCOPE} ${FOLLOW_SCOPE}`;
41
45
 
42
46
  const KEY_ID = "atmosphere-comments-1";
43
47
 
package/lib/settings.ts CHANGED
@@ -13,6 +13,8 @@ export type AtmosphereSettings = {
13
13
  policyUri?: string;
14
14
  tosUri?: string;
15
15
  hidden?: string[];
16
+ locale?: string;
17
+ messages?: Record<string, string>;
16
18
  };
17
19
 
18
20
  export default settings as AtmosphereSettings;
@@ -0,0 +1,36 @@
1
+ // Filling in the component's wording: placeholders and plural forms. Kept
2
+ // apart from lib/i18n.ts so the browser script can use it without bundling
3
+ // every language.
4
+
5
+ export type Messages = Record<string, string>;
6
+
7
+ /**
8
+ * One piece of wording: `vars` fill its {{placeholders}}, and a `count`
9
+ * picks the plural form the language needs. Placeholders not given are
10
+ * left as they are (the component fills some with links).
11
+ */
12
+ export function translate(messages: Messages, locale: string, key: string, vars: Record<string, string | number> = {}): string {
13
+ let text = messages[key];
14
+ if (typeof vars.count === "number") {
15
+ const form = new Intl.PluralRules(locale).select(vars.count);
16
+ text = messages[`${key}_${form}`] ?? messages[`${key}_other`] ?? text;
17
+ }
18
+ text ??= key;
19
+ return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name: string) => (name in vars ? String(vars[name]) : match));
20
+ }
21
+
22
+ export const escapeHtml = (text: string) =>
23
+ text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
24
+
25
+ /** Like translate, for markup: the wording is escaped, and `html` fills placeholders with markup (links, say). */
26
+ export function translateHtml(
27
+ messages: Messages,
28
+ locale: string,
29
+ key: string,
30
+ html: Record<string, string>,
31
+ vars: Record<string, string | number> = {},
32
+ ): string {
33
+ return escapeHtml(translate(messages, locale, key, vars)).replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name: string) =>
34
+ name in html ? html[name] : match,
35
+ );
36
+ }
@@ -0,0 +1,95 @@
1
+ {
2
+ "signIn.heading": "Melde dich an, um zu kommentieren, zu liken oder zu reposten",
3
+ "signIn.anyAccount": "Jedes {{atmosphereAccount}} funktioniert, auch {{bluesky}}: ein Konto für ein ganzes Netzwerk offener Apps.",
4
+ "signIn.atmosphereAccount": "Atmosphere-Konto",
5
+ "signIn.button": "Mit Atmosphere anmelden",
6
+ "signIn.matchingAccounts": "Passende Konten",
7
+ "signIn.privacy": "Du bestätigst das auf der Seite deines eigenen Kontos (für die meisten bei Bluesky); dein Passwort wird hier nie weitergegeben. Noch kein Konto? {{createAccount}}.",
8
+ "signIn.createAccount": "Erstelle ein kostenloses Atmosphere-Konto",
9
+ "elsewhere.text": "Du möchtest dich hier lieber nicht anmelden? {{link}} Deine Antwort erscheint hier genauso, ebenso Likes und Reposts.",
10
+ "elsewhere.link": "Antworte stattdessen auf Bluesky →",
11
+ "actions.label": "Auf diesen Beitrag reagieren",
12
+ "actions.like": "Gefällt mir",
13
+ "actions.liked": "Gefällt dir",
14
+ "actions.repost": "Reposten",
15
+ "actions.reposted": "Repostet",
16
+ "actions.quote": "Zitieren",
17
+ "composer.commentAs": "{{verb}} als {{handle}}",
18
+ "composer.comment": "Kommentieren",
19
+ "composer.quote": "Zitieren",
20
+ "composer.mentions": "Konten zum Erwähnen",
21
+ "composer.previewCaption": "So sieht dein Beitrag aus",
22
+ "composer.now": "jetzt",
23
+ "composer.previewEmpty": "Dein Text erscheint hier.",
24
+ "composer.quoting": "Du zitierst diesen Beitrag mit deinem eigenen Konto",
25
+ "composer.replyingTo": "Antwort an {{name}}",
26
+ "composer.cancel": "Abbrechen",
27
+ "composer.signOut": "Abmelden",
28
+ "composer.postComment": "Kommentar posten",
29
+ "composer.postQuote": "Zitat posten",
30
+ "composer.charactersLeft_one": "Noch {{count}} Zeichen",
31
+ "composer.charactersLeft_other": "Noch {{count}} Zeichen",
32
+ "composer.posting": "Wird gepostet…",
33
+ "posted.comment": "Dein Kommentar ist gepostet.",
34
+ "posted.quote": "Dein Zitat ist auf Bluesky.",
35
+ "posted.see": "Auf Bluesky ansehen →",
36
+ "reactions.likes_one": "{{count}} Like",
37
+ "reactions.likes_other": "{{count}} Likes",
38
+ "reactions.reposts_one": "{{count}} Repost",
39
+ "reactions.reposts_other": "{{count}} Reposts",
40
+ "comment.author": "Autor",
41
+ "comment.viewOnBluesky": "auf Bluesky ansehen",
42
+ "comment.someone": "diesen Kommentar",
43
+ "comment.reply": "Antworten",
44
+ "comment.replyTo": "{{name}} antworten",
45
+ "comment.likeLabel": "Den Kommentar von {{name}} liken",
46
+ "comment.repostLabel": "Den Kommentar von {{name}} reposten",
47
+ "comment.delete": "Löschen",
48
+ "comment.deleteLabel": "Deinen Kommentar löschen",
49
+ "comment.deleteConfirm": "Diesen Kommentar von Bluesky löschen?",
50
+ "comment.deleting": "Wird gelöscht…",
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.",
53
+ "comment.continue": "Weiter zur Bestätigung",
54
+ "comment.cancel": "Abbrechen",
55
+ "signInFirst.reply": "Melde dich an, um zu antworten.",
56
+ "signInFirst.like": "Melde dich an, um Kommentare zu liken.",
57
+ "signInFirst.repost": "Melde dich an, um Kommentare zu reposten.",
58
+ "status.liked": "Geliked.",
59
+ "status.likeRemoved": "Like entfernt.",
60
+ "status.reposted": "Repostet.",
61
+ "status.repostRemoved": "Repost entfernt.",
62
+ "status.signedOut": "Du bist abgemeldet.",
63
+ "messages.signedIn": "Du bist angemeldet. Du kannst oben kommentieren, liken oder reposten.",
64
+ "messages.signInFailed": "Die Anmeldung wurde nicht abgeschlossen. Bitte versuche es noch einmal.",
65
+ "messages.accountNotFound": "Dieses Konto wurde nicht gefunden. Prüfe den Handle und versuche es noch einmal.",
66
+ "messages.invalidHandle": "Das sieht nicht wie ein Handle aus. Meist ist es etwas wie alice.bsky.social.",
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.",
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
+ "messages.tooLong": "Beiträge können bis zu {{max}} Zeichen lang sein.",
72
+ "messages.tooMany": "Du bist ziemlich schnell. Bitte warte eine Minute und versuche es noch einmal.",
73
+ "messages.error": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
74
+ "postActions.replyLabel": "Auf Bluesky antworten",
75
+ "postActions.quoteLabel": "Auf Bluesky zitieren",
76
+ "postActions.likeLabel": "Diesen Beitrag liken",
77
+ "postActions.repostLabel": "Diesen Beitrag reposten",
78
+ "postActions.likeAs": "Als {{handle}} liken",
79
+ "postActions.repostAs": "Als {{handle}} reposten",
80
+ "dialog.heading": "Melde dich an, um zu liken oder zu reposten",
81
+ "dialog.close": "Schließen",
82
+ "account.signedInAs": "Angemeldet als {{handle}}",
83
+ "account.signedInAsLabel": "Angemeldet als",
84
+ "account.signOut": "Abmelden",
85
+ "composer.postReply": "Antwort posten",
86
+ "posted.reply": "Deine Antwort ist gepostet.",
87
+ "posted.notHere": "Sie erscheint nicht auf dieser Seite, aber auf Bluesky.",
88
+ "postActions.replyAs": "Als {{handle}} antworten",
89
+ "postActions.quoteAs": "Als {{handle}} zitieren",
90
+ "follow.follow": "{{handle}} folgen",
91
+ "follow.following": "Folge ich",
92
+ "follow.followAs": "{{handle}} als {{me}} folgen",
93
+ "follow.followingLabel": "Du folgst {{handle}}",
94
+ "credit": "Bereitgestellt von {{link}}"
95
+ }
@@ -0,0 +1,95 @@
1
+ {
2
+ "signIn.heading": "Sign in to leave a comment, like or repost",
3
+ "signIn.anyAccount": "Any {{atmosphereAccount}} works, including {{bluesky}}: one account for a whole network of open apps.",
4
+ "signIn.atmosphereAccount": "Atmosphere account",
5
+ "signIn.button": "Sign in with Atmosphere",
6
+ "signIn.matchingAccounts": "Matching accounts",
7
+ "signIn.privacy": "You'll approve this on your account's own site (Bluesky's, for most people); your password is never shared here. No account yet? {{createAccount}}.",
8
+ "signIn.createAccount": "Create a free Atmosphere account",
9
+ "elsewhere.text": "Rather not sign in here? {{link}} It shows up here just the same, as do likes and reposts.",
10
+ "elsewhere.link": "Reply on Bluesky instead →",
11
+ "actions.label": "Respond to this post",
12
+ "actions.like": "Like",
13
+ "actions.liked": "Liked",
14
+ "actions.repost": "Repost",
15
+ "actions.reposted": "Reposted",
16
+ "actions.quote": "Quote",
17
+ "composer.commentAs": "{{verb}} as {{handle}}",
18
+ "composer.comment": "Comment",
19
+ "composer.quote": "Quote",
20
+ "composer.mentions": "Accounts to mention",
21
+ "composer.previewCaption": "How your post will look",
22
+ "composer.now": "now",
23
+ "composer.previewEmpty": "Your words appear here.",
24
+ "composer.quoting": "Quoting this post on your own account",
25
+ "composer.replyingTo": "Replying to {{name}}",
26
+ "composer.cancel": "Cancel",
27
+ "composer.signOut": "Sign out",
28
+ "composer.postComment": "Post comment",
29
+ "composer.postQuote": "Post quote",
30
+ "composer.charactersLeft_one": "{{count}} left",
31
+ "composer.charactersLeft_other": "{{count}} left",
32
+ "composer.posting": "Posting…",
33
+ "posted.comment": "Your comment is posted.",
34
+ "posted.quote": "Your quote post is on Bluesky.",
35
+ "posted.see": "See it on Bluesky →",
36
+ "reactions.likes_one": "{{count}} like",
37
+ "reactions.likes_other": "{{count}} likes",
38
+ "reactions.reposts_one": "{{count}} repost",
39
+ "reactions.reposts_other": "{{count}} reposts",
40
+ "comment.author": "Author",
41
+ "comment.viewOnBluesky": "view on Bluesky",
42
+ "comment.someone": "this comment",
43
+ "comment.reply": "Reply",
44
+ "comment.replyTo": "Reply to {{name}}",
45
+ "comment.likeLabel": "Like {{name}}'s comment",
46
+ "comment.repostLabel": "Repost {{name}}'s comment",
47
+ "comment.delete": "Delete",
48
+ "comment.deleteLabel": "Delete your comment",
49
+ "comment.deleteConfirm": "Delete this comment from Bluesky?",
50
+ "comment.deleting": "Deleting…",
51
+ "comment.deleted": "Your comment was deleted.",
52
+ "comment.deletePermission": "To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
53
+ "comment.continue": "Continue to approve",
54
+ "comment.cancel": "Cancel",
55
+ "signInFirst.reply": "Sign in to reply.",
56
+ "signInFirst.like": "Sign in to like comments.",
57
+ "signInFirst.repost": "Sign in to repost comments.",
58
+ "status.liked": "Liked.",
59
+ "status.likeRemoved": "Like removed.",
60
+ "status.reposted": "Reposted.",
61
+ "status.repostRemoved": "Repost removed.",
62
+ "status.signedOut": "You're signed out.",
63
+ "messages.signedIn": "You're signed in. You can comment, like or repost above.",
64
+ "messages.signInFailed": "Signing in didn't finish. Please try again.",
65
+ "messages.accountNotFound": "We couldn't find that account. Check the handle and try again.",
66
+ "messages.invalidHandle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
67
+ "messages.signedOut": "You've been signed out. Please sign in again.",
68
+ "messages.needsPermission": "To delete comments here, this site needs one more permission from your account.",
69
+ "messages.notAllowed": "That can't be done here.",
70
+ "messages.newPermissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
71
+ "messages.tooLong": "Posts can be up to {{max}} characters.",
72
+ "messages.tooMany": "You're going quickly. Please wait a minute and try again.",
73
+ "messages.error": "That didn't work. Please try again.",
74
+ "postActions.replyLabel": "Reply on Bluesky",
75
+ "postActions.quoteLabel": "Quote on Bluesky",
76
+ "postActions.likeLabel": "Like this post",
77
+ "postActions.repostLabel": "Repost this post",
78
+ "postActions.likeAs": "Like as {{handle}}",
79
+ "postActions.repostAs": "Repost as {{handle}}",
80
+ "dialog.heading": "Sign in to like or repost",
81
+ "dialog.close": "Close",
82
+ "account.signedInAs": "Signed in as {{handle}}",
83
+ "account.signedInAsLabel": "Signed in as",
84
+ "account.signOut": "Sign out",
85
+ "composer.postReply": "Post reply",
86
+ "posted.reply": "Your reply is posted.",
87
+ "posted.notHere": "It won't show on this page, but it's on Bluesky.",
88
+ "postActions.replyAs": "Reply as {{handle}}",
89
+ "postActions.quoteAs": "Quote as {{handle}}",
90
+ "follow.follow": "Follow {{handle}}",
91
+ "follow.following": "Following",
92
+ "follow.followAs": "Follow {{handle}} as {{me}}",
93
+ "follow.followingLabel": "You follow {{handle}}",
94
+ "credit": "Powered by {{link}}"
95
+ }