@rmdes/indiekit-endpoint-activitypub 2.0.36 → 2.1.1

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/assets/reader.css CHANGED
@@ -2060,189 +2060,294 @@
2060
2060
  font-weight: 600;
2061
2061
  }
2062
2062
 
2063
- /* ---------- Explore: deck toggle button ---------- */
2063
+ /* ==========================================================================
2064
+ Explore: Tabbed Design
2065
+ ========================================================================== */
2064
2066
 
2065
- .ap-explore-deck-toggle {
2066
- display: flex;
2067
- justify-content: flex-end;
2068
- margin-bottom: var(--space-s);
2067
+ /* Tab bar wrapper: enables position:relative for fade gradient overlay */
2068
+ .ap-explore-tabs-container {
2069
+ position: relative;
2069
2070
  }
2070
2071
 
2071
- .ap-explore-deck-toggle__btn {
2072
+ /* Tab bar with right-edge fade to indicate horizontal overflow */
2073
+ .ap-explore-tabs-nav {
2074
+ padding-right: var(--space-l);
2075
+ position: relative;
2076
+ }
2077
+
2078
+ .ap-explore-tabs-nav::after {
2079
+ background: linear-gradient(to right, transparent, var(--color-background, #fff) 80%);
2080
+ content: "";
2081
+ height: 100%;
2082
+ pointer-events: none;
2083
+ position: absolute;
2084
+ right: 0;
2085
+ top: 0;
2086
+ width: 40px;
2087
+ }
2088
+
2089
+ /* Tab wrapper: holds tab button + reorder/close controls together */
2090
+ .ap-tab-wrapper {
2091
+ align-items: stretch;
2092
+ display: inline-flex;
2093
+ position: relative;
2094
+ }
2095
+
2096
+ /* Show controls on hover or when the tab is active */
2097
+ .ap-tab-controls {
2072
2098
  align-items: center;
2099
+ display: none;
2100
+ gap: 1px;
2101
+ }
2102
+
2103
+ .ap-tab-wrapper:hover .ap-tab-controls,
2104
+ .ap-tab-wrapper:focus-within .ap-tab-controls {
2105
+ display: flex;
2106
+ }
2107
+
2108
+ /* Individual control buttons (↑ ↓ ×) */
2109
+ .ap-tab-control {
2073
2110
  background: none;
2074
- border: var(--border-width-thin) solid var(--color-outline);
2075
- border-radius: var(--border-radius-small);
2076
- color: var(--color-on-background);
2111
+ border: none;
2112
+ color: var(--color-on-offset);
2077
2113
  cursor: pointer;
2078
- display: inline-flex;
2114
+ font-size: var(--font-size-xs);
2115
+ line-height: 1;
2116
+ padding: 2px 4px;
2117
+ }
2118
+
2119
+ .ap-tab-control:hover {
2120
+ color: var(--color-on-background);
2121
+ }
2122
+
2123
+ .ap-tab-control:disabled {
2124
+ cursor: default;
2125
+ opacity: 0.3;
2126
+ }
2127
+
2128
+ .ap-tab-control--remove {
2129
+ color: var(--color-on-offset);
2079
2130
  font-size: var(--font-size-s);
2080
- gap: var(--space-2xs);
2081
- padding: var(--space-xs) var(--space-s);
2082
- transition: background 0.15s, color 0.15s, border-color 0.15s;
2083
2131
  }
2084
2132
 
2085
- .ap-explore-deck-toggle__btn:hover:not(:disabled) {
2086
- background: var(--color-offset);
2133
+ .ap-tab-control--remove:hover {
2134
+ color: var(--color-red45);
2087
2135
  }
2088
2136
 
2089
- .ap-explore-deck-toggle__btn--active {
2090
- background: var(--color-accent5);
2091
- border-color: var(--color-accent5);
2092
- color: var(--color-on-accent, #fff);
2137
+ /* Truncate long domain names in tab labels */
2138
+ .ap-tab__label {
2139
+ display: inline-block;
2140
+ max-width: 150px;
2141
+ overflow: hidden;
2142
+ text-overflow: ellipsis;
2143
+ white-space: nowrap;
2093
2144
  }
2094
2145
 
2095
- .ap-explore-deck-toggle__btn--active:hover:not(:disabled) {
2096
- background: var(--color-accent45);
2097
- border-color: var(--color-accent45);
2146
+ /* Scope badges on instance tabs */
2147
+ .ap-tab__badge {
2148
+ border-radius: 3px;
2149
+ font-size: 0.65em;
2150
+ font-weight: 700;
2151
+ letter-spacing: 0.02em;
2152
+ margin-left: var(--space-xs);
2153
+ padding: 1px 4px;
2154
+ text-transform: uppercase;
2155
+ vertical-align: middle;
2098
2156
  }
2099
2157
 
2100
- .ap-explore-deck-toggle__btn:disabled {
2101
- cursor: not-allowed;
2102
- opacity: 0.5;
2158
+ .ap-tab__badge--local {
2159
+ background: color-mix(in srgb, var(--color-blue40, #2563eb) 15%, transparent);
2160
+ color: var(--color-blue40, #2563eb);
2103
2161
  }
2104
2162
 
2105
- /* ---------- Deck grid layout ---------- */
2163
+ .ap-tab__badge--federated {
2164
+ background: color-mix(in srgb, var(--color-purple45, #7c3aed) 15%, transparent);
2165
+ color: var(--color-purple45, #7c3aed);
2166
+ }
2106
2167
 
2107
- .ap-deck-grid {
2108
- display: grid;
2109
- gap: var(--space-m);
2110
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
2111
- margin-top: var(--space-m);
2112
- min-width: 0;
2168
+ /* +# button for adding hashtag tabs */
2169
+ .ap-tab--add {
2170
+ font-family: monospace;
2171
+ font-weight: 700;
2172
+ letter-spacing: -0.05em;
2113
2173
  }
2114
2174
 
2115
- /* ---------- Deck column ---------- */
2175
+ /* Inline hashtag form that appears when +# is clicked */
2176
+ .ap-tab-add-hashtag {
2177
+ align-items: center;
2178
+ display: inline-flex;
2179
+ gap: var(--space-xs);
2180
+ }
2116
2181
 
2117
- .ap-deck-column {
2118
- background: var(--color-offset);
2182
+ .ap-tab-hashtag-form {
2183
+ align-items: center;
2184
+ display: flex;
2185
+ gap: var(--space-xs);
2186
+ }
2187
+
2188
+ .ap-tab-hashtag-form__prefix {
2189
+ color: var(--color-on-offset);
2190
+ font-weight: 600;
2191
+ }
2192
+
2193
+ .ap-tab-hashtag-form__input {
2119
2194
  border: var(--border-width-thin) solid var(--color-outline);
2120
2195
  border-radius: var(--border-radius-small);
2121
- display: flex;
2122
- flex-direction: column;
2123
- max-height: calc(100dvh - 220px);
2124
- min-height: 200px;
2125
- min-width: 0;
2126
- overflow: hidden;
2196
+ font-family: inherit;
2197
+ font-size: var(--font-size-s);
2198
+ padding: 2px var(--space-s);
2199
+ width: 8em;
2200
+ }
2201
+
2202
+ .ap-tab-hashtag-form__input:focus {
2203
+ border-color: var(--color-primary);
2204
+ outline: 2px solid var(--color-primary);
2205
+ outline-offset: -1px;
2127
2206
  }
2128
2207
 
2129
- .ap-deck-column__header {
2208
+ .ap-tab-hashtag-form__btn {
2209
+ background: var(--color-primary);
2210
+ border: none;
2211
+ border-radius: var(--border-radius-small);
2212
+ color: var(--color-on-primary);
2213
+ cursor: pointer;
2214
+ font-family: inherit;
2215
+ font-size: var(--font-size-s);
2216
+ padding: 2px var(--space-s);
2217
+ white-space: nowrap;
2218
+ }
2219
+
2220
+ .ap-tab-hashtag-form__btn:hover {
2221
+ opacity: 0.85;
2222
+ }
2223
+
2224
+ /* "Pin as tab" button in search results area */
2225
+ .ap-explore-pin-bar {
2226
+ margin-bottom: var(--space-s);
2227
+ }
2228
+
2229
+ .ap-explore-pin-btn {
2230
+ background: none;
2231
+ border: var(--border-width-thin) solid var(--color-primary);
2232
+ border-radius: var(--border-radius-small);
2233
+ color: var(--color-primary);
2234
+ cursor: pointer;
2235
+ font-family: inherit;
2236
+ font-size: var(--font-size-s);
2237
+ padding: var(--space-xs) var(--space-m);
2238
+ }
2239
+
2240
+ .ap-explore-pin-btn:hover {
2241
+ background: color-mix(in srgb, var(--color-primary) 10%, transparent);
2242
+ }
2243
+
2244
+ .ap-explore-pin-btn:disabled {
2245
+ cursor: default;
2246
+ opacity: 0.6;
2247
+ }
2248
+
2249
+ /* Hashtag form row inside the search form */
2250
+ .ap-explore-form__hashtag-row {
2130
2251
  align-items: center;
2131
- background: var(--color-background);
2132
- border-bottom: var(--border-width-thin) solid var(--color-outline);
2133
2252
  display: flex;
2134
- flex-shrink: 0;
2253
+ flex-wrap: wrap;
2135
2254
  gap: var(--space-xs);
2136
- padding: var(--space-xs) var(--space-s);
2255
+ margin-top: var(--space-s);
2137
2256
  }
2138
2257
 
2139
- .ap-deck-column__domain {
2258
+ .ap-explore-form__hashtag-label {
2259
+ color: var(--color-on-offset);
2140
2260
  font-size: var(--font-size-s);
2141
- font-weight: 600;
2142
- min-width: 0;
2143
- overflow: hidden;
2144
- text-overflow: ellipsis;
2145
2261
  white-space: nowrap;
2146
2262
  }
2147
2263
 
2148
- .ap-deck-column__scope-badge {
2149
- border-radius: var(--border-radius-small);
2150
- flex-shrink: 0;
2151
- font-size: var(--font-size-xs);
2264
+ .ap-explore-form__hashtag-prefix {
2265
+ color: var(--color-on-offset);
2152
2266
  font-weight: 600;
2153
- padding: 2px var(--space-xs);
2154
- text-transform: uppercase;
2155
2267
  }
2156
2268
 
2157
- .ap-deck-column__scope-badge--local {
2158
- background: var(--color-blue10, #dbeafe);
2159
- color: var(--color-blue50, #1e40af);
2269
+ .ap-explore-form__hashtag-hint {
2270
+ color: var(--color-on-offset);
2271
+ font-size: var(--font-size-xs);
2272
+ flex-basis: 100%;
2160
2273
  }
2161
2274
 
2162
- .ap-deck-column__scope-badge--federated {
2163
- background: var(--color-purple10, #ede9fe);
2164
- color: var(--color-purple50, #5b21b6);
2275
+ .ap-explore-form__input--hashtag {
2276
+ max-width: 200px;
2277
+ width: auto;
2165
2278
  }
2166
2279
 
2167
- .ap-deck-column__remove {
2168
- background: none;
2169
- border: none;
2280
+ /* Tab panel containers */
2281
+ .ap-explore-instance-panel,
2282
+ .ap-explore-hashtag-panel {
2283
+ min-height: 120px;
2284
+ }
2285
+
2286
+ /* Loading state */
2287
+ .ap-explore-tab-loading {
2288
+ align-items: center;
2170
2289
  color: var(--color-on-offset);
2171
- cursor: pointer;
2172
- flex-shrink: 0;
2173
- font-size: 1.2rem;
2174
- line-height: 1;
2175
- margin-left: auto;
2176
- padding: 0 2px;
2290
+ display: flex;
2291
+ justify-content: center;
2292
+ padding: var(--space-xl);
2177
2293
  }
2178
2294
 
2179
- .ap-deck-column__remove:hover {
2180
- color: var(--color-red45);
2295
+ .ap-explore-tab-loading--more {
2296
+ padding-block: var(--space-m);
2181
2297
  }
2182
2298
 
2183
- .ap-deck-column__body {
2184
- flex: 1;
2185
- min-height: 0;
2186
- overflow-y: auto;
2187
- padding: var(--space-xs);
2299
+ .ap-explore-tab-loading__text {
2300
+ font-size: var(--font-size-s);
2188
2301
  }
2189
2302
 
2190
- .ap-deck-column__loading,
2191
- .ap-deck-column__loading-more,
2192
- .ap-deck-column__error,
2193
- .ap-deck-column__empty,
2194
- .ap-deck-column__done {
2195
- color: var(--color-on-offset);
2303
+ /* Error state */
2304
+ .ap-explore-tab-error {
2305
+ align-items: center;
2306
+ display: flex;
2307
+ flex-direction: column;
2308
+ gap: var(--space-s);
2309
+ padding: var(--space-xl);
2310
+ }
2311
+
2312
+ .ap-explore-tab-error__message {
2313
+ color: var(--color-red45);
2196
2314
  font-size: var(--font-size-s);
2197
- padding: var(--space-s);
2198
- text-align: center;
2315
+ margin: 0;
2199
2316
  }
2200
2317
 
2201
- .ap-deck-column__retry {
2318
+ .ap-explore-tab-error__retry {
2202
2319
  background: none;
2203
- border: var(--border-width-thin) solid var(--color-outline);
2320
+ border: 1px solid var(--color-accent);
2204
2321
  border-radius: var(--border-radius-small);
2205
- color: var(--color-on-background);
2322
+ color: var(--color-accent);
2206
2323
  cursor: pointer;
2207
2324
  font-size: var(--font-size-s);
2208
- margin-top: var(--space-xs);
2209
2325
  padding: var(--space-xs) var(--space-s);
2210
2326
  }
2211
2327
 
2212
- .ap-deck-column__retry:hover {
2213
- background: var(--color-offset);
2328
+ .ap-explore-tab-error__retry:hover {
2329
+ background: color-mix(in srgb, var(--color-accent) 10%, transparent);
2214
2330
  }
2215
2331
 
2216
- /* Cards inside deck columns are more compact */
2217
- .ap-deck-column__items .ap-item-card {
2332
+ /* Empty state */
2333
+ .ap-explore-tab-empty {
2334
+ color: var(--color-on-offset);
2218
2335
  font-size: var(--font-size-s);
2219
- }
2220
-
2221
- /* ---------- Deck empty state ---------- */
2222
-
2223
- .ap-deck-empty {
2224
- margin-top: var(--space-xl);
2336
+ padding: var(--space-xl);
2225
2337
  text-align: center;
2226
2338
  }
2227
2339
 
2228
- .ap-deck-empty p {
2229
- color: var(--color-on-offset);
2230
- font-size: var(--font-size-s);
2231
- margin-bottom: var(--space-s);
2340
+ /* Infinite scroll sentinel — zero height, invisible */
2341
+ .ap-tab-sentinel {
2342
+ height: 1px;
2343
+ visibility: hidden;
2232
2344
  }
2233
2345
 
2234
- .ap-deck-empty__link {
2346
+ /* Hashtag tab sources info line */
2347
+ .ap-hashtag-sources {
2348
+ color: var(--color-on-offset);
2235
2349
  font-size: var(--font-size-s);
2350
+ margin: 0;
2351
+ padding: var(--space-s) 0 var(--space-xs);
2236
2352
  }
2237
2353
 
2238
- /* ---------- Deck responsive ---------- */
2239
-
2240
- @media (max-width: 767px) {
2241
- .ap-deck-grid {
2242
- grid-template-columns: 1fr;
2243
- }
2244
-
2245
- .ap-deck-column {
2246
- max-height: 60vh;
2247
- }
2248
- }
package/index.js CHANGED
@@ -70,10 +70,12 @@ import {
70
70
  } from "./lib/controllers/explore.js";
71
71
  import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js";
72
72
  import {
73
- listDecksController,
74
- addDeckController,
75
- removeDeckController,
76
- } from "./lib/controllers/decks.js";
73
+ listTabsController,
74
+ addTabController,
75
+ removeTabController,
76
+ reorderTabsController,
77
+ } from "./lib/controllers/tabs.js";
78
+ import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js";
77
79
  import { publicProfileController } from "./lib/controllers/public-profile.js";
78
80
  import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
79
81
  import { myProfileController } from "./lib/controllers/my-profile.js";
@@ -238,12 +240,14 @@ export default class ActivityPubEndpoint {
238
240
  router.get("/admin/reader/api/timeline", apiTimelineController(mp));
239
241
  router.get("/admin/reader/explore", exploreController(mp));
240
242
  router.get("/admin/reader/api/explore", exploreApiController(mp));
243
+ router.get("/admin/reader/api/explore/hashtag", hashtagExploreApiController(mp));
241
244
  router.get("/admin/reader/api/instances", instanceSearchApiController(mp));
242
245
  router.get("/admin/reader/api/instance-check", instanceCheckApiController(mp));
243
246
  router.get("/admin/reader/api/popular-accounts", popularAccountsApiController(mp));
244
- router.get("/admin/reader/api/decks", listDecksController(mp));
245
- router.post("/admin/reader/api/decks", addDeckController(mp));
246
- router.post("/admin/reader/api/decks/remove", removeDeckController(mp));
247
+ router.get("/admin/reader/api/tabs", listTabsController(mp));
248
+ router.post("/admin/reader/api/tabs", addTabController(mp));
249
+ router.post("/admin/reader/api/tabs/remove", removeTabController(mp));
250
+ router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp));
247
251
  router.post("/admin/reader/follow-tag", followTagController(mp));
248
252
  router.post("/admin/reader/unfollow-tag", unfollowTagController(mp));
249
253
  router.get("/admin/reader/notifications", notificationsController(mp));
@@ -884,8 +888,8 @@ export default class ActivityPubEndpoint {
884
888
  Indiekit.addCollection("ap_interactions");
885
889
  Indiekit.addCollection("ap_notes");
886
890
  Indiekit.addCollection("ap_followed_tags");
887
- // Deck collections
888
- Indiekit.addCollection("ap_decks");
891
+ // Explore tab collections
892
+ Indiekit.addCollection("ap_explore_tabs");
889
893
 
890
894
  // Store collection references (posts resolved lazily)
891
895
  const indiekitCollections = Indiekit.collections;
@@ -906,8 +910,8 @@ export default class ActivityPubEndpoint {
906
910
  ap_interactions: indiekitCollections.get("ap_interactions"),
907
911
  ap_notes: indiekitCollections.get("ap_notes"),
908
912
  ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
909
- // Deck collections
910
- ap_decks: indiekitCollections.get("ap_decks"),
913
+ // Explore tab collections
914
+ ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
911
915
  get posts() {
912
916
  return indiekitCollections.get("posts");
913
917
  },
@@ -1032,11 +1036,19 @@ export default class ActivityPubEndpoint {
1032
1036
  { background: true },
1033
1037
  );
1034
1038
 
1035
- // Deck index — compound unique ensures same instance can appear at most once per scope
1036
- this._collections.ap_decks.createIndex(
1037
- { domain: 1, scope: 1 },
1039
+ // Explore tab indexes
1040
+ // Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
1041
+ // ALL insertions must explicitly set all four fields (unused fields = null)
1042
+ // because MongoDB treats missing fields differently from null in unique indexes.
1043
+ this._collections.ap_explore_tabs.createIndex(
1044
+ { type: 1, domain: 1, scope: 1, hashtag: 1 },
1038
1045
  { unique: true, background: true },
1039
1046
  );
1047
+ // Order index for efficient sorting of tab bar
1048
+ this._collections.ap_explore_tabs.createIndex(
1049
+ { order: 1 },
1050
+ { background: true },
1051
+ );
1040
1052
  } catch {
1041
1053
  // Index creation failed — collections not yet available.
1042
1054
  // Indexes already exist from previous startups; non-fatal.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared utilities for explore controllers.
3
+ *
4
+ * Extracted to break the circular dependency between explore.js and tabs.js:
5
+ * - explore.js needs validateHashtag (was in tabs.js)
6
+ * - tabs.js needs validateInstance (was in explore.js)
7
+ * - hashtag-explore.js needs mapMastodonStatusToItem (was duplicated)
8
+ */
9
+
10
+ import sanitizeHtml from "sanitize-html";
11
+ import { sanitizeContent } from "../timeline-store.js";
12
+
13
+ /**
14
+ * Validate the instance parameter to prevent SSRF.
15
+ * Only allows hostnames — no IPs, no localhost, no port numbers.
16
+ * @param {string} instance - Raw instance parameter from query string
17
+ * @returns {string|null} Validated hostname or null
18
+ */
19
+ export function validateInstance(instance) {
20
+ if (!instance || typeof instance !== "string") return null;
21
+
22
+ try {
23
+ const url = new URL(`https://${instance.trim()}`);
24
+ const hostname = url.hostname;
25
+ if (
26
+ hostname === "localhost" ||
27
+ hostname === "127.0.0.1" ||
28
+ hostname === "0.0.0.0" ||
29
+ hostname === "::1" ||
30
+ hostname.startsWith("192.168.") ||
31
+ hostname.startsWith("10.") ||
32
+ hostname.startsWith("169.254.") ||
33
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
34
+ /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/.test(hostname) ||
35
+ hostname.includes("[")
36
+ ) {
37
+ return null;
38
+ }
39
+
40
+ return hostname;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Validates a hashtag value.
48
+ * Returns the cleaned hashtag (leading # stripped) or null if invalid.
49
+ *
50
+ * Rules match Mastodon's hashtag character rules:
51
+ * - Alphanumeric + underscore only (\w+)
52
+ * - 1–100 characters after stripping leading #
53
+ */
54
+ export function validateHashtag(raw) {
55
+ if (typeof raw !== "string") return null;
56
+ const cleaned = raw.replace(/^#+/, "");
57
+ if (!cleaned || cleaned.length > 100) return null;
58
+ if (!/^[\w]+$/.test(cleaned)) return null;
59
+ return cleaned;
60
+ }
61
+
62
+ /**
63
+ * Map a Mastodon API status object to our timeline item format.
64
+ * @param {object} status - Mastodon API status
65
+ * @param {string} instance - Instance hostname (for handle construction)
66
+ * @returns {object} Timeline item compatible with ap-item-card.njk
67
+ */
68
+ export function mapMastodonStatusToItem(status, instance) {
69
+ const account = status.account || {};
70
+ const acct = account.acct || "";
71
+ const handle = acct.includes("@") ? `@${acct}` : `@${acct}@${instance}`;
72
+
73
+ const mentions = (status.mentions || []).map((m) => ({
74
+ name: m.acct.includes("@") ? m.acct : `${m.acct}@${instance}`,
75
+ url: m.url || "",
76
+ }));
77
+
78
+ const category = (status.tags || []).map((t) => t.name || "");
79
+
80
+ const photo = [];
81
+ const video = [];
82
+ const audio = [];
83
+ for (const att of status.media_attachments || []) {
84
+ const url = att.url || att.remote_url || "";
85
+ if (!url) continue;
86
+ if (att.type === "image" || att.type === "gifv") {
87
+ photo.push(url);
88
+ } else if (att.type === "video") {
89
+ video.push(url);
90
+ } else if (att.type === "audio") {
91
+ audio.push(url);
92
+ }
93
+ }
94
+
95
+ return {
96
+ uid: status.url || status.uri || "",
97
+ url: status.url || status.uri || "",
98
+ type: "note",
99
+ name: "",
100
+ content: {
101
+ text: (status.content || "").replace(/<[^>]*>/g, ""),
102
+ html: sanitizeContent(status.content || ""),
103
+ },
104
+ summary: status.spoiler_text || "",
105
+ sensitive: status.sensitive || false,
106
+ published: status.created_at || new Date().toISOString(),
107
+ author: {
108
+ name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
109
+ url: account.url || "",
110
+ photo: account.avatar || account.avatar_static || "",
111
+ handle,
112
+ },
113
+ category,
114
+ mentions,
115
+ photo,
116
+ video,
117
+ audio,
118
+ inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
119
+ createdAt: new Date().toISOString(),
120
+ _explore: true,
121
+ };
122
+ }