astro-atmosphere-comments 0.3.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/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/locales/de.json CHANGED
@@ -71,5 +71,25 @@
71
71
  "messages.tooLong": "Beiträge können bis zu {{max}} Zeichen lang sein.",
72
72
  "messages.tooMany": "Du bist ziemlich schnell. Bitte warte eine Minute und versuche es noch einmal.",
73
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}}",
74
94
  "credit": "Bereitgestellt von {{link}}"
75
95
  }
package/locales/en.json CHANGED
@@ -71,5 +71,25 @@
71
71
  "messages.tooLong": "Posts can be up to {{max}} characters.",
72
72
  "messages.tooMany": "You're going quickly. Please wait a minute and try again.",
73
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}}",
74
94
  "credit": "Powered by {{link}}"
75
95
  }
package/locales/es.json CHANGED
@@ -74,5 +74,25 @@
74
74
  "messages.tooLong": "Las publicaciones pueden tener hasta {{max}} caracteres.",
75
75
  "messages.tooMany": "Vas muy rápido. Espera un minuto e inténtalo de nuevo.",
76
76
  "messages.error": "No ha funcionado. Inténtalo de nuevo.",
77
+ "postActions.replyLabel": "Responder en Bluesky",
78
+ "postActions.quoteLabel": "Citar en Bluesky",
79
+ "postActions.likeLabel": "Dar me gusta a esta publicación",
80
+ "postActions.repostLabel": "Compartir esta publicación",
81
+ "postActions.likeAs": "Dar me gusta como {{handle}}",
82
+ "postActions.repostAs": "Compartir como {{handle}}",
83
+ "dialog.heading": "Inicia sesión para dar me gusta o compartir",
84
+ "dialog.close": "Cerrar",
85
+ "account.signedInAs": "Sesión iniciada como {{handle}}",
86
+ "account.signedInAsLabel": "Sesión iniciada como",
87
+ "account.signOut": "Cerrar sesión",
88
+ "composer.postReply": "Publicar respuesta",
89
+ "posted.reply": "Tu respuesta está publicada.",
90
+ "posted.notHere": "No aparecerá en esta página, pero está en Bluesky.",
91
+ "postActions.replyAs": "Responder como {{handle}}",
92
+ "postActions.quoteAs": "Citar como {{handle}}",
93
+ "follow.follow": "Seguir a {{handle}}",
94
+ "follow.following": "Siguiendo",
95
+ "follow.followAs": "Seguir a {{handle}} como {{me}}",
96
+ "follow.followingLabel": "Sigues a {{handle}}",
77
97
  "credit": "Funciona con {{link}}"
78
98
  }
package/locales/fr.json CHANGED
@@ -74,5 +74,25 @@
74
74
  "messages.tooLong": "Les publications peuvent contenir jusqu'à {{max}} caractères.",
75
75
  "messages.tooMany": "Tu vas un peu vite. Attends une minute et réessaie.",
76
76
  "messages.error": "Ça n'a pas marché. Réessaie.",
77
+ "postActions.replyLabel": "Répondre sur Bluesky",
78
+ "postActions.quoteLabel": "Citer sur Bluesky",
79
+ "postActions.likeLabel": "Aimer cette publication",
80
+ "postActions.repostLabel": "Republier cette publication",
81
+ "postActions.likeAs": "Aimer en tant que {{handle}}",
82
+ "postActions.repostAs": "Republier en tant que {{handle}}",
83
+ "dialog.heading": "Connecte-toi pour aimer ou republier",
84
+ "dialog.close": "Fermer",
85
+ "account.signedInAs": "Connecté en tant que {{handle}}",
86
+ "account.signedInAsLabel": "Connecté en tant que",
87
+ "account.signOut": "Se déconnecter",
88
+ "composer.postReply": "Publier la réponse",
89
+ "posted.reply": "Ta réponse est publiée.",
90
+ "posted.notHere": "Elle ne s'affichera pas sur cette page, mais elle est sur Bluesky.",
91
+ "postActions.replyAs": "Répondre en tant que {{handle}}",
92
+ "postActions.quoteAs": "Citer en tant que {{handle}}",
93
+ "follow.follow": "Suivre {{handle}}",
94
+ "follow.following": "Abonné",
95
+ "follow.followAs": "Suivre {{handle}} en tant que {{me}}",
96
+ "follow.followingLabel": "Tu suis {{handle}}",
77
97
  "credit": "Propulsé par {{link}}"
78
98
  }
package/locales/it.json CHANGED
@@ -74,5 +74,25 @@
74
74
  "messages.tooLong": "I post possono avere fino a {{max}} caratteri.",
75
75
  "messages.tooMany": "Stai andando un po' veloce. Aspetta un minuto e riprova.",
76
76
  "messages.error": "Non ha funzionato. Riprova.",
77
+ "postActions.replyLabel": "Rispondi su Bluesky",
78
+ "postActions.quoteLabel": "Cita su Bluesky",
79
+ "postActions.likeLabel": "Metti mi piace a questo post",
80
+ "postActions.repostLabel": "Ripubblica questo post",
81
+ "postActions.likeAs": "Metti mi piace come {{handle}}",
82
+ "postActions.repostAs": "Ripubblica come {{handle}}",
83
+ "dialog.heading": "Accedi per mettere mi piace o ripubblicare",
84
+ "dialog.close": "Chiudi",
85
+ "account.signedInAs": "Accesso effettuato come {{handle}}",
86
+ "account.signedInAsLabel": "Accesso effettuato come",
87
+ "account.signOut": "Esci",
88
+ "composer.postReply": "Pubblica risposta",
89
+ "posted.reply": "La tua risposta è pubblicata.",
90
+ "posted.notHere": "Non comparirà in questa pagina, ma è su Bluesky.",
91
+ "postActions.replyAs": "Rispondi come {{handle}}",
92
+ "postActions.quoteAs": "Cita come {{handle}}",
93
+ "follow.follow": "Segui {{handle}}",
94
+ "follow.following": "Segui già",
95
+ "follow.followAs": "Segui {{handle}} come {{me}}",
96
+ "follow.followingLabel": "Segui {{handle}}",
77
97
  "credit": "Realizzato con {{link}}"
78
98
  }
package/locales/ja.json CHANGED
@@ -68,5 +68,25 @@
68
68
  "messages.tooLong": "投稿は{{max}}文字までです。",
69
69
  "messages.tooMany": "操作が早すぎます。1分待ってから、もう一度お試しください。",
70
70
  "messages.error": "うまくいきませんでした。もう一度お試しください。",
71
+ "postActions.replyLabel": "Blueskyで返信",
72
+ "postActions.quoteLabel": "Blueskyで引用",
73
+ "postActions.likeLabel": "この投稿にいいね",
74
+ "postActions.repostLabel": "この投稿をリポスト",
75
+ "postActions.likeAs": "{{handle}}としていいね",
76
+ "postActions.repostAs": "{{handle}}としてリポスト",
77
+ "dialog.heading": "ログインしていいねやリポストをする",
78
+ "dialog.close": "閉じる",
79
+ "account.signedInAs": "{{handle}}としてログイン中",
80
+ "account.signedInAsLabel": "ログイン中のアカウント",
81
+ "account.signOut": "ログアウト",
82
+ "composer.postReply": "返信を投稿",
83
+ "posted.reply": "返信を投稿しました。",
84
+ "posted.notHere": "このページには表示されませんが、Blueskyに投稿されています。",
85
+ "postActions.replyAs": "{{handle}}として返信",
86
+ "postActions.quoteAs": "{{handle}}として引用",
87
+ "follow.follow": "{{handle}}をフォロー",
88
+ "follow.following": "フォロー中",
89
+ "follow.followAs": "{{me}}として{{handle}}をフォロー",
90
+ "follow.followingLabel": "{{handle}}をフォローしています",
71
91
  "credit": "{{link}}で動作しています"
72
92
  }
package/locales/nl.json CHANGED
@@ -71,5 +71,25 @@
71
71
  "messages.tooLong": "Berichten mogen maximaal {{max}} tekens lang zijn.",
72
72
  "messages.tooMany": "Je gaat wel snel. Wacht een minuut en probeer het opnieuw.",
73
73
  "messages.error": "Dat is niet gelukt. Probeer het opnieuw.",
74
+ "postActions.replyLabel": "Reageren op Bluesky",
75
+ "postActions.quoteLabel": "Citeren op Bluesky",
76
+ "postActions.likeLabel": "Dit bericht liken",
77
+ "postActions.repostLabel": "Dit bericht reposten",
78
+ "postActions.likeAs": "Liken als {{handle}}",
79
+ "postActions.repostAs": "Reposten als {{handle}}",
80
+ "dialog.heading": "Log in om te liken of te reposten",
81
+ "dialog.close": "Sluiten",
82
+ "account.signedInAs": "Ingelogd als {{handle}}",
83
+ "account.signedInAsLabel": "Ingelogd als",
84
+ "account.signOut": "Uitloggen",
85
+ "composer.postReply": "Reactie plaatsen",
86
+ "posted.reply": "Je reactie is geplaatst.",
87
+ "posted.notHere": "Hij verschijnt niet op deze pagina, maar staat wel op Bluesky.",
88
+ "postActions.replyAs": "Reageren als {{handle}}",
89
+ "postActions.quoteAs": "Citeren als {{handle}}",
90
+ "follow.follow": "{{handle}} volgen",
91
+ "follow.following": "Volgend",
92
+ "follow.followAs": "{{handle}} volgen als {{me}}",
93
+ "follow.followingLabel": "Je volgt {{handle}}",
74
94
  "credit": "Mogelijk gemaakt door {{link}}"
75
95
  }
package/locales/pt.json CHANGED
@@ -74,5 +74,25 @@
74
74
  "messages.tooLong": "As publicações podem ter até {{max}} caracteres.",
75
75
  "messages.tooMany": "Você está indo rápido demais. Espere um minuto e tente de novo.",
76
76
  "messages.error": "Não deu certo. Tente de novo.",
77
+ "postActions.replyLabel": "Responder no Bluesky",
78
+ "postActions.quoteLabel": "Citar no Bluesky",
79
+ "postActions.likeLabel": "Curtir esta publicação",
80
+ "postActions.repostLabel": "Repostar esta publicação",
81
+ "postActions.likeAs": "Curtir como {{handle}}",
82
+ "postActions.repostAs": "Repostar como {{handle}}",
83
+ "dialog.heading": "Entre para curtir ou repostar",
84
+ "dialog.close": "Fechar",
85
+ "account.signedInAs": "Conectado como {{handle}}",
86
+ "account.signedInAsLabel": "Conectado como",
87
+ "account.signOut": "Sair",
88
+ "composer.postReply": "Publicar resposta",
89
+ "posted.reply": "Sua resposta foi publicada.",
90
+ "posted.notHere": "Ela não aparece nesta página, mas está no Bluesky.",
91
+ "postActions.replyAs": "Responder como {{handle}}",
92
+ "postActions.quoteAs": "Citar como {{handle}}",
93
+ "follow.follow": "Seguir {{handle}}",
94
+ "follow.following": "Seguindo",
95
+ "follow.followAs": "Seguir {{handle}} como {{me}}",
96
+ "follow.followingLabel": "Você segue {{handle}}",
77
97
  "credit": "Feito com {{link}}"
78
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-atmosphere-comments",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Atmosphere Comments for Astro: Bluesky comments, likes, reposts and quote posts for Astro sites; readers sign in with their Atmosphere (AT Protocol) account and respond without leaving the page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,14 +20,18 @@
20
20
  "atmosphere",
21
21
  "comments",
22
22
  "likes",
23
- "oauth"
23
+ "oauth",
24
+ "at-protocol",
25
+ "reposts",
26
+ "i18n"
24
27
  ],
25
28
  "exports": {
26
29
  ".": {
27
30
  "types": "./index.d.ts",
28
31
  "default": "./index.mjs"
29
32
  },
30
- "./AtmosphereComments.astro": "./components/AtmosphereComments.astro"
33
+ "./AtmosphereComments.astro": "./components/AtmosphereComments.astro",
34
+ "./AtmosphereReactions.astro": "./components/AtmosphereReactions.astro"
31
35
  },
32
36
  "types": "./index.d.ts",
33
37
  "bin": {