@rmdes/indiekit-endpoint-conversations 2.1.3 → 2.1.5

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.
@@ -100,10 +100,12 @@ export async function runPollCycle(indiekit, options) {
100
100
  }
101
101
 
102
102
  /**
103
- * Backfill empty author.photo fields in conversation_items using ap_notifications.
104
- * The AP endpoint stores actorPhoto in notifications for all interactions,
105
- * so we can use those to enrich conversation_items that were stored before
106
- * the actorAvatar field was added to ap_activities.
103
+ * Backfill empty author.photo fields in conversation_items.
104
+ * Tries four strategies in order:
105
+ * 1. ap_notifications (actorPhoto)
106
+ * 2. ap_activities (actorAvatar — new field from inbox handler fix)
107
+ * 3. ap_followers (avatar)
108
+ * 4. Live fetch of the actor's ActivityPub profile (icon field)
107
109
  */
108
110
  async function backfillMissingAvatars(indiekit, stateCollection) {
109
111
  try {
@@ -118,20 +120,16 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
118
120
  const state = await stateCollection.findOne({ _id: "poll_cursors" });
119
121
  if (state?.avatar_backfill_complete) return;
120
122
 
121
- // Find conversation_items with empty author.photo
122
- const itemsWithoutPhoto = await itemsCollection
123
- .find({
124
- $or: [
125
- { "author.photo": "" },
126
- { "author.photo": null },
127
- { "author.photo": { $exists: false } },
128
- ],
129
- })
130
- .limit(200)
131
- .toArray();
132
-
133
- if (itemsWithoutPhoto.length === 0) {
134
- // Mark backfill as complete so we don't query every cycle
123
+ // Get unique actor URLs with empty photos (deduplicate)
124
+ const actorUrls = await itemsCollection.distinct("author.url", {
125
+ $or: [
126
+ { "author.photo": "" },
127
+ { "author.photo": null },
128
+ { "author.photo": { $exists: false } },
129
+ ],
130
+ });
131
+
132
+ if (actorUrls.length === 0) {
135
133
  await stateCollection.findOneAndUpdate(
136
134
  { _id: "poll_cursors" },
137
135
  { $set: { avatar_backfill_complete: true } },
@@ -142,13 +140,12 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
142
140
 
143
141
  let updated = 0;
144
142
 
145
- for (const item of itemsWithoutPhoto) {
146
- const actorUrl = item.author?.url;
143
+ for (const actorUrl of actorUrls) {
147
144
  if (!actorUrl) continue;
148
145
 
149
146
  let photo = "";
150
147
 
151
- // Strategy 1: Check ap_notifications (most reliable — has actorPhoto for all interaction types)
148
+ // Strategy 1: Check ap_notifications
152
149
  if (!photo && notificationsCollection) {
153
150
  try {
154
151
  const notification = await notificationsCollection.findOne({
@@ -159,7 +156,7 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
159
156
  } catch { /* ignore */ }
160
157
  }
161
158
 
162
- // Strategy 2: Check ap_activities for actorAvatar (new field from inbox handler fix)
159
+ // Strategy 2: Check ap_activities for actorAvatar
163
160
  if (!photo && activitiesCollection) {
164
161
  try {
165
162
  const activity = await activitiesCollection.findOne({
@@ -178,6 +175,25 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
178
175
  } catch { /* ignore */ }
179
176
  }
180
177
 
178
+ // Strategy 4: Fetch actor profile from the fediverse (last resort)
179
+ if (!photo) {
180
+ try {
181
+ const resp = await fetch(actorUrl, {
182
+ headers: { Accept: "application/activity+json, application/ld+json" },
183
+ signal: AbortSignal.timeout(5000),
184
+ });
185
+ if (resp.ok) {
186
+ const actor = await resp.json();
187
+ const icon = actor.icon;
188
+ if (typeof icon === "string") {
189
+ photo = icon;
190
+ } else if (icon?.url) {
191
+ photo = icon.url;
192
+ }
193
+ }
194
+ } catch { /* timeout or network error — skip */ }
195
+ }
196
+
181
197
  if (photo) {
182
198
  await itemsCollection.updateMany(
183
199
  { "author.url": actorUrl, "author.photo": { $in: ["", null] } },
@@ -189,18 +205,16 @@ async function backfillMissingAvatars(indiekit, stateCollection) {
189
205
 
190
206
  if (updated > 0) {
191
207
  console.info(
192
- `[Conversations] Avatar backfill: updated ${updated} actors with photos`,
208
+ `[Conversations] Avatar backfill: updated ${updated}/${actorUrls.length} actors with photos`,
193
209
  );
194
210
  }
195
211
 
196
- // If fewer than 200 items found, backfill is complete
197
- if (itemsWithoutPhoto.length < 200) {
198
- await stateCollection.findOneAndUpdate(
199
- { _id: "poll_cursors" },
200
- { $set: { avatar_backfill_complete: true } },
201
- { upsert: true },
202
- );
203
- }
212
+ // Mark complete all actors have been attempted
213
+ await stateCollection.findOneAndUpdate(
214
+ { _id: "poll_cursors" },
215
+ { $set: { avatar_backfill_complete: true } },
216
+ { upsert: true },
217
+ );
204
218
  } catch (error) {
205
219
  // Non-critical — log and continue
206
220
  console.warn("[Conversations] Avatar backfill error:", error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "Conversation aggregation endpoint for Indiekit. Backend enrichment service that polls Mastodon/Bluesky notifications and serves JF2-compatible data for the interactions page.",
5
5
  "keywords": [
6
6
  "indiekit",