@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-infinite-scroll.js +6 -0
- package/assets/reader-tabs.js +643 -0
- package/assets/reader.css +228 -117
- package/index.js +26 -14
- package/lib/controllers/explore-utils.js +122 -0
- package/lib/controllers/explore.js +28 -142
- package/lib/controllers/hashtag-explore.js +223 -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/views/partials/ap-item-card.njk +33 -0
- package/assets/reader-decks.js +0 -212
- package/lib/controllers/decks.js +0 -137
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
|
-
/*
|
|
2063
|
+
/* ==========================================================================
|
|
2064
|
+
Explore: Tabbed Design
|
|
2065
|
+
========================================================================== */
|
|
2058
2066
|
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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
|
-
|
|
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:
|
|
2069
|
-
|
|
2070
|
-
color: var(--color-on-background);
|
|
2111
|
+
border: none;
|
|
2112
|
+
color: var(--color-on-offset);
|
|
2071
2113
|
cursor: pointer;
|
|
2072
|
-
|
|
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-
|
|
2080
|
-
|
|
2133
|
+
.ap-tab-control--remove:hover {
|
|
2134
|
+
color: var(--color-red45);
|
|
2081
2135
|
}
|
|
2082
2136
|
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2090
|
-
|
|
2091
|
-
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;
|
|
2092
2156
|
}
|
|
2093
2157
|
|
|
2094
|
-
.ap-
|
|
2095
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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
|
-
/*
|
|
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-
|
|
2112
|
-
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
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-
|
|
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-
|
|
2253
|
+
flex-wrap: wrap;
|
|
2129
2254
|
gap: var(--space-xs);
|
|
2130
|
-
|
|
2255
|
+
margin-top: var(--space-s);
|
|
2131
2256
|
}
|
|
2132
2257
|
|
|
2133
|
-
.ap-
|
|
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-
|
|
2143
|
-
|
|
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-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2280
|
+
/* Tab panel containers */
|
|
2281
|
+
.ap-explore-instance-panel,
|
|
2282
|
+
.ap-explore-hashtag-panel {
|
|
2283
|
+
min-height: 120px;
|
|
2159
2284
|
}
|
|
2160
2285
|
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2286
|
+
/* Loading state */
|
|
2287
|
+
.ap-explore-tab-loading {
|
|
2288
|
+
align-items: center;
|
|
2164
2289
|
color: var(--color-on-offset);
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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-
|
|
2174
|
-
|
|
2295
|
+
.ap-explore-tab-loading--more {
|
|
2296
|
+
padding-block: var(--space-m);
|
|
2175
2297
|
}
|
|
2176
2298
|
|
|
2177
|
-
.ap-
|
|
2178
|
-
|
|
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
|
-
|
|
2185
|
-
.ap-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
2192
|
-
text-align: center;
|
|
2315
|
+
margin: 0;
|
|
2193
2316
|
}
|
|
2194
2317
|
|
|
2195
|
-
.ap-
|
|
2318
|
+
.ap-explore-tab-error__retry {
|
|
2196
2319
|
background: none;
|
|
2197
|
-
border:
|
|
2320
|
+
border: 1px solid var(--color-accent);
|
|
2198
2321
|
border-radius: var(--border-radius-small);
|
|
2199
|
-
color: var(--color-
|
|
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-
|
|
2207
|
-
background: var(--color-
|
|
2328
|
+
.ap-explore-tab-error__retry:hover {
|
|
2329
|
+
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
2208
2330
|
}
|
|
2209
2331
|
|
|
2210
|
-
/*
|
|
2211
|
-
.ap-
|
|
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
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2340
|
+
/* Infinite scroll sentinel — zero height, invisible */
|
|
2341
|
+
.ap-tab-sentinel {
|
|
2342
|
+
height: 1px;
|
|
2343
|
+
visibility: hidden;
|
|
2226
2344
|
}
|
|
2227
2345
|
|
|
2228
|
-
|
|
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
|
-
|
|
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
|
+
}
|