@rmdes/indiekit-endpoint-activitypub 2.4.5 → 2.5.4

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/README.md CHANGED
@@ -14,13 +14,23 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
14
14
  - Configurable actor type (Person, Service, Organization, Group)
15
15
 
16
16
  **Reader**
17
- - Timeline view showing posts from followed accounts
17
+ - Timeline view showing posts from followed accounts with tab filtering (notes, articles, replies, boosts, media)
18
+ - Explore view — browse public timelines from any Mastodon-compatible instance
19
+ - Cross-instance hashtag search — search a hashtag across multiple fediverse instances
20
+ - Tag timeline — view and follow/unfollow specific hashtags
21
+ - Post detail view with threaded context
22
+ - Quote post embeds — quoted posts render as inline cards with author, content, and timestamp (FEP-044f, Misskey, Fedibird formats)
23
+ - Link preview cards via Open Graph metadata unfurling
18
24
  - Notifications for likes, boosts, follows, mentions, and replies
19
25
  - Compose form with dual-path posting (quick AP reply or Micropub blog post)
20
26
  - Native interactions (like, boost, reply, follow/unfollow from the reader)
21
27
  - Remote actor profile pages
22
28
  - Content warnings and sensitive content handling
23
29
  - Media display (images, video, audio)
30
+ - Infinite scroll with IntersectionObserver-based auto-loading
31
+ - New post banner — polls for new items and offers one-click loading
32
+ - Read tracking — marks posts as read on scroll, with unread filter toggle
33
+ - Popular accounts autocomplete in the fediverse lookup bar
24
34
  - Configurable timeline retention
25
35
 
26
36
  **Moderation**
@@ -220,7 +230,11 @@ All admin pages are behind IndieAuth authentication:
220
230
  | Page | Path | Description |
221
231
  |---|---|---|
222
232
  | Dashboard | `/activitypub` | Overview with follower/following counts, recent activity |
223
- | Reader | `/activitypub/admin/reader` | Timeline from followed accounts |
233
+ | Reader | `/activitypub/admin/reader` | Timeline from followed accounts (tabbed: notes, articles, replies, boosts, media) |
234
+ | Explore | `/activitypub/admin/reader/explore` | Browse public timelines from Mastodon-compatible instances |
235
+ | Hashtag Explore | `/activitypub/admin/reader/explore/hashtag` | Search a hashtag across multiple fediverse instances |
236
+ | Tag Timeline | `/activitypub/admin/reader/tag?tag=name` | Posts filtered by a specific hashtag, with follow/unfollow |
237
+ | Post Detail | `/activitypub/admin/reader/post?url=...` | Single post view with quote embeds and link previews |
224
238
  | Notifications | `/activitypub/admin/reader/notifications` | Likes, boosts, follows, mentions, replies |
225
239
  | Compose | `/activitypub/admin/reader/compose` | Reply composer (quick AP or Micropub) |
226
240
  | Moderation | `/activitypub/admin/reader/moderation` | Muted/blocked accounts and keywords |
@@ -329,6 +343,7 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
329
343
  - **Single actor** — One fediverse identity per Indiekit instance
330
344
  - **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
331
345
  - **No image upload in reader** — Compose form is text-only
346
+ - **No custom emoji rendering** — Custom emoji shortcodes display as text
332
347
  - **In-process queue without Redis** — Activities may be lost on restart
333
348
 
334
349
  ## License
@@ -1,121 +1,55 @@
1
1
  /**
2
- * Infinite scroll — AlpineJS component for AJAX load-more on the timeline
3
- * Registers the `apInfiniteScroll` Alpine data component.
2
+ * Infinite scroll — unified AlpineJS component for AJAX load-more.
3
+ * Works for both reader timeline and explore view via data attributes.
4
+ *
5
+ * Required data attributes on the component element:
6
+ * data-cursor — initial pagination cursor value
7
+ * data-api-url — API endpoint URL (e.g., /activitypub/admin/reader/api/timeline)
8
+ * data-cursor-param — query param name for the cursor (e.g., "before" or "max_id")
9
+ * data-cursor-field — response JSON field for the next cursor (e.g., "before" or "maxId")
10
+ * data-timeline-id — DOM ID of the timeline container to append HTML into
11
+ *
12
+ * Optional:
13
+ * data-extra-params — JSON-encoded object of additional query params
14
+ * data-hide-pagination — CSS selector of no-JS pagination to hide
4
15
  */
5
16
 
6
17
  document.addEventListener("alpine:init", () => {
7
18
  // eslint-disable-next-line no-undef
8
- Alpine.data("apExploreScroll", () => ({
19
+ Alpine.data("apInfiniteScroll", () => ({
9
20
  loading: false,
10
21
  done: false,
11
- maxId: null,
12
- instance: "",
13
- scope: "local",
14
- hashtag: "",
22
+ cursor: null,
23
+ apiUrl: "",
24
+ cursorParam: "before",
25
+ cursorField: "before",
26
+ timelineId: "",
27
+ extraParams: {},
15
28
  observer: null,
16
29
 
17
30
  init() {
18
31
  const el = this.$el;
19
- this.maxId = el.dataset.maxId || null;
20
- this.instance = el.dataset.instance || "";
21
- this.scope = el.dataset.scope || "local";
22
- this.hashtag = el.dataset.hashtag || "";
23
-
24
- if (!this.maxId) {
25
- this.done = true;
26
- return;
27
- }
28
-
29
- this.observer = new IntersectionObserver(
30
- (entries) => {
31
- for (const entry of entries) {
32
- if (entry.isIntersecting && !this.loading && !this.done) {
33
- this.loadMore();
34
- }
35
- }
36
- },
37
- { rootMargin: "200px" }
38
- );
39
-
40
- if (this.$refs.sentinel) {
41
- this.observer.observe(this.$refs.sentinel);
42
- }
43
- },
44
-
45
- async loadMore() {
46
- if (this.loading || this.done || !this.maxId) return;
47
-
48
- this.loading = true;
49
-
50
- const timeline = document.getElementById("ap-explore-timeline");
51
- const mountPath = timeline ? timeline.dataset.mountPath : "";
52
-
53
- const params = new URLSearchParams({
54
- instance: this.instance,
55
- scope: this.scope,
56
- max_id: this.maxId,
57
- });
58
- // Pass hashtag when in hashtag mode so infinite scroll stays on tag timeline
59
- if (this.hashtag) {
60
- params.set("hashtag", this.hashtag);
61
- }
32
+ this.cursor = el.dataset.cursor || null;
33
+ this.apiUrl = el.dataset.apiUrl || "";
34
+ this.cursorParam = el.dataset.cursorParam || "before";
35
+ this.cursorField = el.dataset.cursorField || "before";
36
+ this.timelineId = el.dataset.timelineId || "";
62
37
 
38
+ // Parse extra params from JSON data attribute
63
39
  try {
64
- const res = await fetch(
65
- `${mountPath}/admin/reader/api/explore?${params.toString()}`,
66
- { headers: { Accept: "application/json" } }
67
- );
68
-
69
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
70
-
71
- const data = await res.json();
72
-
73
- if (data.html && timeline) {
74
- timeline.insertAdjacentHTML("beforeend", data.html);
75
- }
76
-
77
- if (data.maxId) {
78
- this.maxId = data.maxId;
79
- } else {
80
- this.done = true;
81
- if (this.observer) this.observer.disconnect();
82
- }
83
- } catch (err) {
84
- console.error("[ap-explore-scroll] load failed:", err.message);
85
- } finally {
86
- this.loading = false;
40
+ this.extraParams = JSON.parse(el.dataset.extraParams || "{}");
41
+ } catch {
42
+ this.extraParams = {};
87
43
  }
88
- },
89
-
90
- destroy() {
91
- if (this.observer) this.observer.disconnect();
92
- },
93
- }));
94
-
95
- // eslint-disable-next-line no-undef
96
- Alpine.data("apInfiniteScroll", () => ({
97
- loading: false,
98
- done: false,
99
- before: null,
100
- tab: "",
101
- tag: "",
102
- observer: null,
103
-
104
- init() {
105
- const el = this.$el;
106
- this.before = el.dataset.before || null;
107
- this.tab = el.dataset.tab || "";
108
- this.tag = el.dataset.tag || "";
109
44
 
110
45
  // Hide the no-JS pagination fallback now that JS is active
111
- const paginationEl =
112
- document.getElementById("ap-reader-pagination") ||
113
- document.getElementById("ap-tag-pagination");
114
- if (paginationEl) {
115
- paginationEl.style.display = "none";
46
+ const hideSel = el.dataset.hidePagination;
47
+ if (hideSel) {
48
+ const paginationEl = document.getElementById(hideSel);
49
+ if (paginationEl) paginationEl.style.display = "none";
116
50
  }
117
51
 
118
- if (!this.before) {
52
+ if (!this.cursor) {
119
53
  this.done = true;
120
54
  return;
121
55
  }
@@ -129,7 +63,7 @@ document.addEventListener("alpine:init", () => {
129
63
  }
130
64
  }
131
65
  },
132
- { rootMargin: "200px" }
66
+ { rootMargin: "200px" },
133
67
  );
134
68
 
135
69
  if (this.$refs.sentinel) {
@@ -138,36 +72,36 @@ document.addEventListener("alpine:init", () => {
138
72
  },
139
73
 
140
74
  async loadMore() {
141
- if (this.loading || this.done || !this.before) return;
75
+ if (this.loading || this.done || !this.cursor) return;
142
76
 
143
77
  this.loading = true;
144
78
 
145
- const timeline = document.getElementById("ap-timeline");
146
- const mountPath = timeline ? timeline.dataset.mountPath : "";
147
-
148
- const params = new URLSearchParams({ before: this.before });
149
- if (this.tab) params.set("tab", this.tab);
150
- if (this.tag) params.set("tag", this.tag);
79
+ const params = new URLSearchParams({
80
+ [this.cursorParam]: this.cursor,
81
+ ...this.extraParams,
82
+ });
151
83
 
152
84
  try {
153
85
  const res = await fetch(
154
- `${mountPath}/admin/reader/api/timeline?${params.toString()}`,
155
- { headers: { Accept: "application/json" } }
86
+ `${this.apiUrl}?${params.toString()}`,
87
+ { headers: { Accept: "application/json" } },
156
88
  );
157
89
 
158
90
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
159
91
 
160
92
  const data = await res.json();
161
93
 
94
+ const timeline = this.timelineId
95
+ ? document.getElementById(this.timelineId)
96
+ : null;
97
+
162
98
  if (data.html && timeline) {
163
- // Append the returned pre-rendered HTML
164
99
  timeline.insertAdjacentHTML("beforeend", data.html);
165
100
  }
166
101
 
167
- if (data.before) {
168
- this.before = data.before;
102
+ if (data[this.cursorField]) {
103
+ this.cursor = data[this.cursorField];
169
104
  } else {
170
- // No more items
171
105
  this.done = true;
172
106
  if (this.observer) this.observer.disconnect();
173
107
  }
@@ -178,10 +112,6 @@ document.addEventListener("alpine:init", () => {
178
112
  }
179
113
  },
180
114
 
181
- appendItems(/* detail */) {
182
- // Custom event hook — not used in this implementation but kept for extensibility
183
- },
184
-
185
115
  destroy() {
186
116
  if (this.observer) this.observer.disconnect();
187
117
  },
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Relative timestamps — Alpine.js directive that converts absolute
3
+ * datetime attributes to human-friendly relative strings.
4
+ *
5
+ * Usage: <time datetime="2026-03-03T12:00:00Z" x-data x-relative-time>...</time>
6
+ *
7
+ * The server-rendered absolute time stays as fallback for no-JS clients.
8
+ * Alpine enhances it to relative on hydration, updates every 60s for
9
+ * recent posts, and shows the absolute time on hover via title attribute.
10
+ *
11
+ * Format rules (matching Mastodon/Elk conventions):
12
+ * < 1 minute: "just now"
13
+ * < 60 minutes: "Xm" (e.g. "5m")
14
+ * < 24 hours: "Xh" (e.g. "3h")
15
+ * < 7 days: "Xd" (e.g. "2d")
16
+ * same year: "Mar 3"
17
+ * older: "Mar 3, 2025"
18
+ */
19
+
20
+ document.addEventListener("alpine:init", () => {
21
+ // eslint-disable-next-line no-undef
22
+ Alpine.directive("relative-time", (el) => {
23
+ const iso = el.getAttribute("datetime");
24
+ if (!iso) return;
25
+
26
+ const date = new Date(iso);
27
+ if (Number.isNaN(date.getTime())) return;
28
+
29
+ // Store the original formatted text as the title (hover tooltip)
30
+ const original = el.textContent.trim();
31
+ if (original && !el.getAttribute("title")) {
32
+ el.setAttribute("title", original);
33
+ }
34
+
35
+ function update() {
36
+ el.textContent = formatRelative(date);
37
+ }
38
+
39
+ update();
40
+
41
+ // Only set up interval for recent posts (< 24h old)
42
+ const ageMs = Date.now() - date.getTime();
43
+ if (ageMs < 86_400_000) {
44
+ const interval = setInterval(() => {
45
+ update();
46
+ // Stop updating once older than 24h
47
+ if (Date.now() - date.getTime() >= 86_400_000) {
48
+ clearInterval(interval);
49
+ }
50
+ }, 60_000);
51
+ }
52
+ });
53
+ });
54
+
55
+ /**
56
+ * Format a Date as a relative time string.
57
+ * @param {Date} date
58
+ * @returns {string}
59
+ */
60
+ function formatRelative(date) {
61
+ const now = Date.now();
62
+ const diffMs = now - date.getTime();
63
+ const diffSec = Math.floor(diffMs / 1000);
64
+
65
+ if (diffSec < 60) return "just now";
66
+
67
+ const diffMin = Math.floor(diffSec / 60);
68
+ if (diffMin < 60) return `${diffMin}m`;
69
+
70
+ const diffHour = Math.floor(diffMin / 60);
71
+ if (diffHour < 24) return `${diffHour}h`;
72
+
73
+ const diffDay = Math.floor(diffHour / 24);
74
+ if (diffDay < 7) return `${diffDay}d`;
75
+
76
+ // Older than 7 days — use formatted date
77
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
78
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
79
+ const month = months[date.getMonth()];
80
+ const day = date.getDate();
81
+
82
+ if (date.getFullYear() === new Date().getFullYear()) {
83
+ return `${month} ${day}`;
84
+ }
85
+
86
+ return `${month} ${day}, ${date.getFullYear()}`;
87
+ }
package/assets/reader.css CHANGED
@@ -763,6 +763,14 @@
763
763
  opacity: 0.6;
764
764
  }
765
765
 
766
+ /* Interaction counts */
767
+ .ap-card__count {
768
+ font-size: var(--font-size-xs);
769
+ color: var(--color-on-offset);
770
+ margin-left: 0.25rem;
771
+ font-variant-numeric: tabular-nums;
772
+ }
773
+
766
774
  /* Error message */
767
775
  .ap-card__action-error {
768
776
  color: var(--color-error);
@@ -2528,3 +2536,54 @@
2528
2536
  padding: var(--space-s) 0 var(--space-xs);
2529
2537
  }
2530
2538
 
2539
+ /* Custom emoji */
2540
+ .ap-custom-emoji {
2541
+ height: 1.2em;
2542
+ width: auto;
2543
+ vertical-align: middle;
2544
+ display: inline;
2545
+ margin: 0 0.05em;
2546
+ }
2547
+
2548
+ /* Gallery items — positioned for ALT badge overlay */
2549
+ .ap-card__gallery-item {
2550
+ position: relative;
2551
+ }
2552
+
2553
+ /* ALT text badges */
2554
+ .ap-media__alt-badge {
2555
+ position: absolute;
2556
+ bottom: 0.5rem;
2557
+ left: 0.5rem;
2558
+ background: rgba(0, 0, 0, 0.7);
2559
+ color: white;
2560
+ font-size: 0.65rem;
2561
+ font-weight: 700;
2562
+ padding: 0.15rem 0.35rem;
2563
+ border-radius: var(--border-radius-small);
2564
+ border: none;
2565
+ cursor: pointer;
2566
+ text-transform: uppercase;
2567
+ letter-spacing: 0.03em;
2568
+ z-index: 1;
2569
+ }
2570
+
2571
+ .ap-media__alt-badge:hover {
2572
+ background: rgba(0, 0, 0, 0.9);
2573
+ }
2574
+
2575
+ .ap-media__alt-text {
2576
+ position: absolute;
2577
+ bottom: 2.2rem;
2578
+ left: 0.5rem;
2579
+ right: 0.5rem;
2580
+ background: rgba(0, 0, 0, 0.85);
2581
+ color: white;
2582
+ font-size: var(--font-size-s);
2583
+ padding: 0.5rem;
2584
+ border-radius: var(--border-radius-small);
2585
+ max-height: 8rem;
2586
+ overflow-y: auto;
2587
+ z-index: 2;
2588
+ }
2589
+
@@ -4,13 +4,7 @@
4
4
 
5
5
  import { getTimelineItems, countNewItems, markItemsRead } from "../storage/timeline.js";
6
6
  import { getToken, validateToken } from "../csrf.js";
7
- import {
8
- getMutedUrls,
9
- getMutedKeywords,
10
- getBlockedUrls,
11
- getFilterMode,
12
- } from "../storage/moderation.js";
13
- import { stripQuoteReferenceHtml } from "../og-unfurl.js";
7
+ import { postProcessItems, applyTabFilter, loadModerationData, renderItemCards } from "../item-processing.js";
14
8
 
15
9
  export function apiTimelineController(mountPath) {
16
10
  return async (request, response, next) => {
@@ -26,7 +20,7 @@ export function apiTimelineController(mountPath) {
26
20
  const before = request.query.before;
27
21
  const limit = 20;
28
22
 
29
- // Build storage query options (same logic as readerController)
23
+ // Build storage query options
30
24
  const unread = request.query.unread === "1";
31
25
  const options = { before, limit, unread };
32
26
 
@@ -45,132 +39,32 @@ export function apiTimelineController(mountPath) {
45
39
 
46
40
  const result = await getTimelineItems(collections, options);
47
41
 
48
- // Client-side tab filtering for types not supported by storage
49
- let items = result.items;
50
- if (!tag) {
51
- if (tab === "replies") {
52
- items = items.filter((item) => item.inReplyTo);
53
- } else if (tab === "media") {
54
- items = items.filter(
55
- (item) =>
56
- (item.photo && item.photo.length > 0) ||
57
- (item.video && item.video.length > 0) ||
58
- (item.audio && item.audio.length > 0)
59
- );
60
- }
61
- }
42
+ // Tab filtering for types not supported by storage layer
43
+ const tabFiltered = tag ? result.items : applyTabFilter(result.items, tab);
62
44
 
63
- // Apply moderation filters
45
+ // Shared processing pipeline: moderation, quote stripping, interactions
64
46
  const modCollections = {
65
47
  ap_muted: application?.collections?.get("ap_muted"),
66
48
  ap_blocked: application?.collections?.get("ap_blocked"),
67
49
  ap_profile: application?.collections?.get("ap_profile"),
68
50
  };
69
- const [mutedUrls, mutedKeywords, blockedUrls, filterMode] =
70
- await Promise.all([
71
- getMutedUrls(modCollections),
72
- getMutedKeywords(modCollections),
73
- getBlockedUrls(modCollections),
74
- getFilterMode(modCollections),
75
- ]);
76
- const blockedSet = new Set(blockedUrls);
77
- const mutedSet = new Set(mutedUrls);
78
-
79
- if (blockedSet.size > 0 || mutedSet.size > 0 || mutedKeywords.length > 0) {
80
- items = items.filter((item) => {
81
- if (item.author?.url && blockedSet.has(item.author.url)) {
82
- return false;
83
- }
84
-
85
- const isMutedActor = item.author?.url && mutedSet.has(item.author.url);
86
- let matchedKeyword = null;
87
- if (mutedKeywords.length > 0) {
88
- const searchable = [item.content?.text, item.name, item.summary]
89
- .filter(Boolean)
90
- .join(" ")
91
- .toLowerCase();
92
- if (searchable) {
93
- matchedKeyword = mutedKeywords.find((kw) =>
94
- searchable.includes(kw.toLowerCase())
95
- );
96
- }
97
- }
98
-
99
- if (isMutedActor || matchedKeyword) {
100
- if (filterMode === "warn") {
101
- item._moderated = true;
102
- item._moderationReason = isMutedActor ? "muted_account" : "muted_keyword";
103
- if (matchedKeyword) item._moderationKeyword = matchedKeyword;
104
- return true;
105
- }
106
- return false;
107
- }
108
-
109
- return true;
110
- });
111
- }
112
-
113
- // Get interaction state
114
- const interactionsCol = application?.collections?.get("ap_interactions");
115
- const interactionMap = {};
116
-
117
- if (interactionsCol) {
118
- const lookupUrls = new Set();
119
- const objectUrlToUid = new Map();
120
- for (const item of items) {
121
- const uid = item.uid;
122
- const displayUrl = item.url || item.originalUrl;
123
- if (uid) { lookupUrls.add(uid); objectUrlToUid.set(uid, uid); }
124
- if (displayUrl) { lookupUrls.add(displayUrl); objectUrlToUid.set(displayUrl, uid || displayUrl); }
125
- }
126
- if (lookupUrls.size > 0) {
127
- const interactions = await interactionsCol
128
- .find({ objectUrl: { $in: [...lookupUrls] } })
129
- .toArray();
130
- for (const interaction of interactions) {
131
- const key = objectUrlToUid.get(interaction.objectUrl) || interaction.objectUrl;
132
- if (!interactionMap[key]) interactionMap[key] = {};
133
- interactionMap[key][interaction.type] = true;
134
- }
135
- }
136
- }
51
+ const moderation = await loadModerationData(modCollections);
137
52
 
138
- // Strip "RE:" paragraphs from items that have quote embeds
139
- for (const item of items) {
140
- const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
141
- if (item.quote && quoteRef && item.content?.html) {
142
- item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
143
- }
144
- }
53
+ const { items, interactionMap } = await postProcessItems(tabFiltered, {
54
+ moderation,
55
+ interactionsCol: application?.collections?.get("ap_interactions"),
56
+ });
145
57
 
146
58
  const csrfToken = getToken(request.session);
147
-
148
- // Render each card server-side using the same Nunjucks template
149
- // Merge response.locals so that i18n (__), mountPath, etc. are available
150
- const templateData = {
59
+ const html = await renderItemCards(items, request, {
151
60
  ...response.locals,
152
61
  mountPath,
153
62
  csrfToken,
154
63
  interactionMap,
155
- };
156
-
157
- const htmlParts = await Promise.all(
158
- items.map((item) => {
159
- return new Promise((resolve, reject) => {
160
- request.app.render(
161
- "partials/ap-item-card.njk",
162
- { ...templateData, item },
163
- (err, html) => {
164
- if (err) reject(err);
165
- else resolve(html);
166
- }
167
- );
168
- });
169
- })
170
- );
64
+ });
171
65
 
172
66
  response.json({
173
- html: htmlParts.join(""),
67
+ html,
174
68
  before: result.before,
175
69
  });
176
70
  } catch (error) {
@@ -84,7 +84,14 @@ export function mapMastodonStatusToItem(status, instance) {
84
84
  const url = att.url || att.remote_url || "";
85
85
  if (!url) continue;
86
86
  if (att.type === "image" || att.type === "gifv") {
87
- photo.push(url);
87
+ photo.push({
88
+ url,
89
+ alt: att.description || "",
90
+ width: att.meta?.original?.width || null,
91
+ height: att.meta?.original?.height || null,
92
+ blurhash: att.blurhash || "",
93
+ focus: att.meta?.focus || null,
94
+ });
88
95
  } else if (att.type === "video") {
89
96
  video.push(url);
90
97
  } else if (att.type === "audio") {
@@ -92,6 +99,14 @@ export function mapMastodonStatusToItem(status, instance) {
92
99
  }
93
100
  }
94
101
 
102
+ // Extract custom emoji — Mastodon API provides emojis on both status and account
103
+ const emojis = (status.emojis || [])
104
+ .filter((e) => e.shortcode && e.url)
105
+ .map((e) => ({ shortcode: e.shortcode, url: e.url }));
106
+ const authorEmojis = (account.emojis || [])
107
+ .filter((e) => e.shortcode && e.url)
108
+ .map((e) => ({ shortcode: e.shortcode, url: e.url }));
109
+
95
110
  const item = {
96
111
  uid: status.url || status.uri || "",
97
112
  url: status.url || status.uri || "",
@@ -109,13 +124,20 @@ export function mapMastodonStatusToItem(status, instance) {
109
124
  url: account.url || "",
110
125
  photo: account.avatar || account.avatar_static || "",
111
126
  handle,
127
+ emojis: authorEmojis,
112
128
  },
113
129
  category,
114
130
  mentions,
131
+ emojis,
115
132
  photo,
116
133
  video,
117
134
  audio,
118
135
  inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
136
+ counts: {
137
+ replies: status.replies_count ?? null,
138
+ boosts: status.reblogs_count ?? null,
139
+ likes: status.favourites_count ?? null,
140
+ },
119
141
  createdAt: new Date().toISOString(),
120
142
  _explore: true,
121
143
  };
@@ -134,7 +156,12 @@ export function mapMastodonStatusToItem(status, instance) {
134
156
  for (const att of q.media_attachments || []) {
135
157
  const attUrl = att.url || att.remote_url || "";
136
158
  if (attUrl && (att.type === "image" || att.type === "gifv")) {
137
- qPhoto.push(attUrl);
159
+ qPhoto.push({
160
+ url: attUrl,
161
+ alt: att.description || "",
162
+ width: att.meta?.original?.width || null,
163
+ height: att.meta?.original?.height || null,
164
+ });
138
165
  }
139
166
  }
140
167