@rmdes/indiekit-endpoint-conversations 2.1.2 → 2.1.3

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.
@@ -48,7 +48,11 @@ export async function fetchActivityPubInteractions(options) {
48
48
  const items = [];
49
49
 
50
50
  for (const activity of activities) {
51
- const avatar = await lookupAvatar(ap_followers, activity.actorUrl);
51
+ // Prefer avatar stored directly on the activity (added by inbox handler),
52
+ // fall back to ap_followers lookup for historical data without actorAvatar
53
+ const avatar =
54
+ activity.actorAvatar ||
55
+ (await lookupAvatar(ap_followers, activity.actorUrl));
52
56
  items.push(normalizeActivity(activity, avatar));
53
57
  }
54
58
 
@@ -94,6 +94,117 @@ export async function runPollCycle(indiekit, options) {
94
94
  }
95
95
  await pollActivityPub(indiekit, stateCollection, state);
96
96
  }
97
+
98
+ // Backfill missing avatars from ap_notifications (one-time sweep per cycle)
99
+ await backfillMissingAvatars(indiekit, stateCollection);
100
+ }
101
+
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.
107
+ */
108
+ async function backfillMissingAvatars(indiekit, stateCollection) {
109
+ try {
110
+ const itemsCollection = indiekit.collections.get("conversation_items");
111
+ const notificationsCollection = indiekit.collections.get("ap_notifications");
112
+ const activitiesCollection = indiekit.collections.get("ap_activities");
113
+ const followersCollection = indiekit.collections.get("ap_followers");
114
+
115
+ if (!itemsCollection) return;
116
+
117
+ // Check if backfill already completed
118
+ const state = await stateCollection.findOne({ _id: "poll_cursors" });
119
+ if (state?.avatar_backfill_complete) return;
120
+
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
135
+ await stateCollection.findOneAndUpdate(
136
+ { _id: "poll_cursors" },
137
+ { $set: { avatar_backfill_complete: true } },
138
+ { upsert: true },
139
+ );
140
+ return;
141
+ }
142
+
143
+ let updated = 0;
144
+
145
+ for (const item of itemsWithoutPhoto) {
146
+ const actorUrl = item.author?.url;
147
+ if (!actorUrl) continue;
148
+
149
+ let photo = "";
150
+
151
+ // Strategy 1: Check ap_notifications (most reliable — has actorPhoto for all interaction types)
152
+ if (!photo && notificationsCollection) {
153
+ try {
154
+ const notification = await notificationsCollection.findOne({
155
+ actorUrl,
156
+ actorPhoto: { $ne: "" },
157
+ });
158
+ if (notification?.actorPhoto) photo = notification.actorPhoto;
159
+ } catch { /* ignore */ }
160
+ }
161
+
162
+ // Strategy 2: Check ap_activities for actorAvatar (new field from inbox handler fix)
163
+ if (!photo && activitiesCollection) {
164
+ try {
165
+ const activity = await activitiesCollection.findOne({
166
+ actorUrl,
167
+ actorAvatar: { $ne: "" },
168
+ });
169
+ if (activity?.actorAvatar) photo = activity.actorAvatar;
170
+ } catch { /* ignore */ }
171
+ }
172
+
173
+ // Strategy 3: Check ap_followers
174
+ if (!photo && followersCollection) {
175
+ try {
176
+ const follower = await followersCollection.findOne({ actorUrl });
177
+ if (follower?.avatar) photo = follower.avatar;
178
+ } catch { /* ignore */ }
179
+ }
180
+
181
+ if (photo) {
182
+ await itemsCollection.updateMany(
183
+ { "author.url": actorUrl, "author.photo": { $in: ["", null] } },
184
+ { $set: { "author.photo": photo } },
185
+ );
186
+ updated++;
187
+ }
188
+ }
189
+
190
+ if (updated > 0) {
191
+ console.info(
192
+ `[Conversations] Avatar backfill: updated ${updated} actors with photos`,
193
+ );
194
+ }
195
+
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
+ }
204
+ } catch (error) {
205
+ // Non-critical — log and continue
206
+ console.warn("[Conversations] Avatar backfill error:", error.message);
207
+ }
97
208
  }
98
209
 
99
210
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-conversations",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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",