@rmdes/indiekit-endpoint-activitypub 2.0.35 → 2.1.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.
package/assets/reader.css CHANGED
@@ -752,6 +752,12 @@
752
752
  color: var(--color-green50);
753
753
  }
754
754
 
755
+ .ap-card__action--save.ap-card__action--active {
756
+ background: #4a9eff22;
757
+ border-color: #4a9eff;
758
+ color: #4a9eff;
759
+ }
760
+
755
761
  .ap-card__action:disabled {
756
762
  cursor: wait;
757
763
  opacity: 0.6;
@@ -2054,189 +2060,294 @@
2054
2060
  font-weight: 600;
2055
2061
  }
2056
2062
 
2057
- /* ---------- Explore: deck toggle button ---------- */
2063
+ /* ==========================================================================
2064
+ Explore: Tabbed Design
2065
+ ========================================================================== */
2058
2066
 
2059
- .ap-explore-deck-toggle {
2060
- display: flex;
2061
- justify-content: flex-end;
2062
- margin-bottom: var(--space-s);
2067
+ /* Tab bar wrapper: enables position:relative for fade gradient overlay */
2068
+ .ap-explore-tabs-container {
2069
+ position: relative;
2070
+ }
2071
+
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;
2063
2094
  }
2064
2095
 
2065
- .ap-explore-deck-toggle__btn {
2096
+ /* Show controls on hover or when the tab is active */
2097
+ .ap-tab-controls {
2066
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 {
2067
2110
  background: none;
2068
- border: var(--border-width-thin) solid var(--color-outline);
2069
- border-radius: var(--border-radius-small);
2070
- color: var(--color-on-background);
2111
+ border: none;
2112
+ color: var(--color-on-offset);
2071
2113
  cursor: pointer;
2072
- 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);
2073
2130
  font-size: var(--font-size-s);
2074
- gap: var(--space-2xs);
2075
- padding: var(--space-xs) var(--space-s);
2076
- transition: background 0.15s, color 0.15s, border-color 0.15s;
2077
2131
  }
2078
2132
 
2079
- .ap-explore-deck-toggle__btn:hover:not(:disabled) {
2080
- background: var(--color-offset);
2133
+ .ap-tab-control--remove:hover {
2134
+ color: var(--color-red45);
2081
2135
  }
2082
2136
 
2083
- .ap-explore-deck-toggle__btn--active {
2084
- background: var(--color-accent5);
2085
- border-color: var(--color-accent5);
2086
- 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;
2087
2144
  }
2088
2145
 
2089
- .ap-explore-deck-toggle__btn--active:hover:not(:disabled) {
2090
- background: var(--color-accent45);
2091
- 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;
2092
2156
  }
2093
2157
 
2094
- .ap-explore-deck-toggle__btn:disabled {
2095
- cursor: not-allowed;
2096
- 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);
2097
2161
  }
2098
2162
 
2099
- /* ---------- 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
+ }
2100
2167
 
2101
- .ap-deck-grid {
2102
- display: grid;
2103
- gap: var(--space-m);
2104
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
2105
- margin-top: var(--space-m);
2106
- 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;
2107
2173
  }
2108
2174
 
2109
- /* ---------- 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
+ }
2110
2181
 
2111
- .ap-deck-column {
2112
- 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 {
2113
2194
  border: var(--border-width-thin) solid var(--color-outline);
2114
2195
  border-radius: var(--border-radius-small);
2115
- display: flex;
2116
- flex-direction: column;
2117
- max-height: calc(100dvh - 220px);
2118
- min-height: 200px;
2119
- min-width: 0;
2120
- overflow: hidden;
2196
+ font-family: inherit;
2197
+ font-size: var(--font-size-s);
2198
+ padding: 2px var(--space-s);
2199
+ width: 8em;
2121
2200
  }
2122
2201
 
2123
- .ap-deck-column__header {
2202
+ .ap-tab-hashtag-form__input:focus {
2203
+ border-color: var(--color-primary);
2204
+ outline: 2px solid var(--color-primary);
2205
+ outline-offset: -1px;
2206
+ }
2207
+
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 {
2124
2251
  align-items: center;
2125
- background: var(--color-background);
2126
- border-bottom: var(--border-width-thin) solid var(--color-outline);
2127
2252
  display: flex;
2128
- flex-shrink: 0;
2253
+ flex-wrap: wrap;
2129
2254
  gap: var(--space-xs);
2130
- padding: var(--space-xs) var(--space-s);
2255
+ margin-top: var(--space-s);
2131
2256
  }
2132
2257
 
2133
- .ap-deck-column__domain {
2258
+ .ap-explore-form__hashtag-label {
2259
+ color: var(--color-on-offset);
2134
2260
  font-size: var(--font-size-s);
2135
- font-weight: 600;
2136
- min-width: 0;
2137
- overflow: hidden;
2138
- text-overflow: ellipsis;
2139
2261
  white-space: nowrap;
2140
2262
  }
2141
2263
 
2142
- .ap-deck-column__scope-badge {
2143
- border-radius: var(--border-radius-small);
2144
- flex-shrink: 0;
2145
- font-size: var(--font-size-xs);
2264
+ .ap-explore-form__hashtag-prefix {
2265
+ color: var(--color-on-offset);
2146
2266
  font-weight: 600;
2147
- padding: 2px var(--space-xs);
2148
- text-transform: uppercase;
2149
2267
  }
2150
2268
 
2151
- .ap-deck-column__scope-badge--local {
2152
- background: var(--color-blue10, #dbeafe);
2153
- 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%;
2273
+ }
2274
+
2275
+ .ap-explore-form__input--hashtag {
2276
+ max-width: 200px;
2277
+ width: auto;
2154
2278
  }
2155
2279
 
2156
- .ap-deck-column__scope-badge--federated {
2157
- background: var(--color-purple10, #ede9fe);
2158
- color: var(--color-purple50, #5b21b6);
2280
+ /* Tab panel containers */
2281
+ .ap-explore-instance-panel,
2282
+ .ap-explore-hashtag-panel {
2283
+ min-height: 120px;
2159
2284
  }
2160
2285
 
2161
- .ap-deck-column__remove {
2162
- background: none;
2163
- border: none;
2286
+ /* Loading state */
2287
+ .ap-explore-tab-loading {
2288
+ align-items: center;
2164
2289
  color: var(--color-on-offset);
2165
- cursor: pointer;
2166
- flex-shrink: 0;
2167
- font-size: 1.2rem;
2168
- line-height: 1;
2169
- margin-left: auto;
2170
- padding: 0 2px;
2290
+ display: flex;
2291
+ justify-content: center;
2292
+ padding: var(--space-xl);
2171
2293
  }
2172
2294
 
2173
- .ap-deck-column__remove:hover {
2174
- color: var(--color-red45);
2295
+ .ap-explore-tab-loading--more {
2296
+ padding-block: var(--space-m);
2175
2297
  }
2176
2298
 
2177
- .ap-deck-column__body {
2178
- flex: 1;
2179
- min-height: 0;
2180
- overflow-y: auto;
2181
- padding: var(--space-xs);
2299
+ .ap-explore-tab-loading__text {
2300
+ font-size: var(--font-size-s);
2182
2301
  }
2183
2302
 
2184
- .ap-deck-column__loading,
2185
- .ap-deck-column__loading-more,
2186
- .ap-deck-column__error,
2187
- .ap-deck-column__empty,
2188
- .ap-deck-column__done {
2189
- 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);
2190
2314
  font-size: var(--font-size-s);
2191
- padding: var(--space-s);
2192
- text-align: center;
2315
+ margin: 0;
2193
2316
  }
2194
2317
 
2195
- .ap-deck-column__retry {
2318
+ .ap-explore-tab-error__retry {
2196
2319
  background: none;
2197
- border: var(--border-width-thin) solid var(--color-outline);
2320
+ border: 1px solid var(--color-accent);
2198
2321
  border-radius: var(--border-radius-small);
2199
- color: var(--color-on-background);
2322
+ color: var(--color-accent);
2200
2323
  cursor: pointer;
2201
2324
  font-size: var(--font-size-s);
2202
- margin-top: var(--space-xs);
2203
2325
  padding: var(--space-xs) var(--space-s);
2204
2326
  }
2205
2327
 
2206
- .ap-deck-column__retry:hover {
2207
- background: var(--color-offset);
2328
+ .ap-explore-tab-error__retry:hover {
2329
+ background: color-mix(in srgb, var(--color-accent) 10%, transparent);
2208
2330
  }
2209
2331
 
2210
- /* Cards inside deck columns are more compact */
2211
- .ap-deck-column__items .ap-item-card {
2332
+ /* Empty state */
2333
+ .ap-explore-tab-empty {
2334
+ color: var(--color-on-offset);
2212
2335
  font-size: var(--font-size-s);
2213
- }
2214
-
2215
- /* ---------- Deck empty state ---------- */
2216
-
2217
- .ap-deck-empty {
2218
- margin-top: var(--space-xl);
2336
+ padding: var(--space-xl);
2219
2337
  text-align: center;
2220
2338
  }
2221
2339
 
2222
- .ap-deck-empty p {
2223
- color: var(--color-on-offset);
2224
- font-size: var(--font-size-s);
2225
- margin-bottom: var(--space-s);
2340
+ /* Infinite scroll sentinel — zero height, invisible */
2341
+ .ap-tab-sentinel {
2342
+ height: 1px;
2343
+ visibility: hidden;
2226
2344
  }
2227
2345
 
2228
- .ap-deck-empty__link {
2346
+ /* Hashtag tab sources info line */
2347
+ .ap-hashtag-sources {
2348
+ color: var(--color-on-offset);
2229
2349
  font-size: var(--font-size-s);
2350
+ margin: 0;
2351
+ padding: var(--space-s) 0 var(--space-xs);
2230
2352
  }
2231
2353
 
2232
- /* ---------- Deck responsive ---------- */
2233
-
2234
- @media (max-width: 767px) {
2235
- .ap-deck-grid {
2236
- grid-template-columns: 1fr;
2237
- }
2238
-
2239
- .ap-deck-column {
2240
- max-height: 60vh;
2241
- }
2242
- }
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
+ }