@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-infinite-scroll.js +6 -0
- package/assets/reader-tabs.js +643 -0
- package/assets/reader.css +222 -117
- package/index.js +26 -14
- package/lib/controllers/explore-utils.js +122 -0
- package/lib/controllers/explore.js +30 -143
- package/lib/controllers/hashtag-explore.js +225 -0
- package/lib/controllers/tabs.js +245 -0
- package/locales/en.json +16 -13
- package/package.json +1 -1
- package/views/activitypub-explore.njk +364 -193
- package/views/layouts/ap-reader.njk +2 -2
- package/assets/reader-decks.js +0 -212
- package/lib/controllers/decks.js +0 -137
package/assets/reader.css
CHANGED
|
@@ -2060,189 +2060,294 @@
|
|
|
2060
2060
|
font-weight: 600;
|
|
2061
2061
|
}
|
|
2062
2062
|
|
|
2063
|
-
/*
|
|
2063
|
+
/* ==========================================================================
|
|
2064
|
+
Explore: Tabbed Design
|
|
2065
|
+
========================================================================== */
|
|
2064
2066
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
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:
|
|
2075
|
-
|
|
2076
|
-
color: var(--color-on-background);
|
|
2111
|
+
border: none;
|
|
2112
|
+
color: var(--color-on-offset);
|
|
2077
2113
|
cursor: pointer;
|
|
2078
|
-
|
|
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-
|
|
2086
|
-
|
|
2133
|
+
.ap-tab-control--remove:hover {
|
|
2134
|
+
color: var(--color-red45);
|
|
2087
2135
|
}
|
|
2088
2136
|
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
2096
|
-
|
|
2097
|
-
border-
|
|
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-
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
/*
|
|
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-
|
|
2118
|
-
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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-
|
|
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-
|
|
2253
|
+
flex-wrap: wrap;
|
|
2135
2254
|
gap: var(--space-xs);
|
|
2136
|
-
|
|
2255
|
+
margin-top: var(--space-s);
|
|
2137
2256
|
}
|
|
2138
2257
|
|
|
2139
|
-
.ap-
|
|
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-
|
|
2149
|
-
|
|
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-
|
|
2158
|
-
|
|
2159
|
-
|
|
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-
|
|
2163
|
-
|
|
2164
|
-
|
|
2275
|
+
.ap-explore-form__input--hashtag {
|
|
2276
|
+
max-width: 200px;
|
|
2277
|
+
width: auto;
|
|
2165
2278
|
}
|
|
2166
2279
|
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
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-
|
|
2180
|
-
|
|
2295
|
+
.ap-explore-tab-loading--more {
|
|
2296
|
+
padding-block: var(--space-m);
|
|
2181
2297
|
}
|
|
2182
2298
|
|
|
2183
|
-
.ap-
|
|
2184
|
-
|
|
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
|
-
|
|
2191
|
-
.ap-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
-
|
|
2198
|
-
text-align: center;
|
|
2315
|
+
margin: 0;
|
|
2199
2316
|
}
|
|
2200
2317
|
|
|
2201
|
-
.ap-
|
|
2318
|
+
.ap-explore-tab-error__retry {
|
|
2202
2319
|
background: none;
|
|
2203
|
-
border:
|
|
2320
|
+
border: 1px solid var(--color-accent);
|
|
2204
2321
|
border-radius: var(--border-radius-small);
|
|
2205
|
-
color: var(--color-
|
|
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-
|
|
2213
|
-
background: var(--color-
|
|
2328
|
+
.ap-explore-tab-error__retry:hover {
|
|
2329
|
+
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
2214
2330
|
}
|
|
2215
2331
|
|
|
2216
|
-
/*
|
|
2217
|
-
.ap-
|
|
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
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2340
|
+
/* Infinite scroll sentinel — zero height, invisible */
|
|
2341
|
+
.ap-tab-sentinel {
|
|
2342
|
+
height: 1px;
|
|
2343
|
+
visibility: hidden;
|
|
2232
2344
|
}
|
|
2233
2345
|
|
|
2234
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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/
|
|
245
|
-
router.post("/admin/reader/api/
|
|
246
|
-
router.post("/admin/reader/api/
|
|
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
|
-
//
|
|
888
|
-
Indiekit.addCollection("
|
|
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
|
-
//
|
|
910
|
-
|
|
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
|
-
//
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
}
|