@rmdes/indiekit-endpoint-activitypub 2.5.5 → 2.6.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-blurhash.js +66 -0
- package/assets/reader-links.css +3 -3
- package/assets/reader.css +117 -30
- package/lib/content-utils.js +72 -0
- package/lib/controllers/explore-utils.js +2 -0
- package/lib/item-processing.js +23 -1
- package/lib/timeline-store.js +9 -2
- package/package.json +1 -1
- package/views/layouts/ap-reader.njk +2 -0
- package/views/partials/ap-item-card.njk +2 -0
- package/views/partials/ap-item-media.njk +9 -1
- package/views/partials/ap-quote-embed.njk +7 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blurhash placeholder backgrounds for gallery images.
|
|
3
|
+
*
|
|
4
|
+
* Extracts the average (DC) color from a blurhash string and applies it
|
|
5
|
+
* as a background-color on images with a data-blurhash attribute.
|
|
6
|
+
* This provides a meaningful colored placeholder while images load.
|
|
7
|
+
*
|
|
8
|
+
* The DC component is encoded in the first 4 characters of the blurhash
|
|
9
|
+
* after the size byte, as a base-83 integer representing an sRGB color.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const BASE83_CHARS =
|
|
13
|
+
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
|
14
|
+
|
|
15
|
+
function decode83(str) {
|
|
16
|
+
let value = 0;
|
|
17
|
+
for (const c of str) {
|
|
18
|
+
const digit = BASE83_CHARS.indexOf(c);
|
|
19
|
+
if (digit === -1) return 0;
|
|
20
|
+
value = value * 83 + digit;
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decodeDC(value) {
|
|
26
|
+
return {
|
|
27
|
+
r: (value >> 16) & 255,
|
|
28
|
+
g: (value >> 8) & 255,
|
|
29
|
+
b: value & 255,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function blurhashToColor(hash) {
|
|
34
|
+
if (!hash || hash.length < 6) return null;
|
|
35
|
+
const dcValue = decode83(hash.slice(1, 5));
|
|
36
|
+
const { r, g, b } = decodeDC(dcValue);
|
|
37
|
+
return `rgb(${r},${g},${b})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
41
|
+
for (const img of document.querySelectorAll("img[data-blurhash]")) {
|
|
42
|
+
const color = blurhashToColor(img.dataset.blurhash);
|
|
43
|
+
if (color) {
|
|
44
|
+
img.style.backgroundColor = color;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle dynamically loaded images (infinite scroll)
|
|
49
|
+
const observer = new MutationObserver((mutations) => {
|
|
50
|
+
for (const mutation of mutations) {
|
|
51
|
+
for (const node of mutation.addedNodes) {
|
|
52
|
+
if (node.nodeType !== 1) continue;
|
|
53
|
+
const imgs = node.querySelectorAll
|
|
54
|
+
? node.querySelectorAll("img[data-blurhash]")
|
|
55
|
+
: [];
|
|
56
|
+
for (const img of imgs) {
|
|
57
|
+
const color = blurhashToColor(img.dataset.blurhash);
|
|
58
|
+
if (color) {
|
|
59
|
+
img.style.backgroundColor = color;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
66
|
+
});
|
package/assets/reader-links.css
CHANGED
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
.ap-link-preview {
|
|
16
16
|
display: flex;
|
|
17
17
|
overflow: hidden;
|
|
18
|
-
border-radius:
|
|
18
|
+
border-radius: 8px;
|
|
19
19
|
border: 1px solid var(--color-neutral-lighter);
|
|
20
20
|
background-color: var(--color-offset);
|
|
21
21
|
text-decoration: none;
|
|
22
22
|
color: inherit;
|
|
23
|
-
transition: border-color 0.
|
|
23
|
+
transition: border-color 0.15s ease;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
.ap-link-preview:hover {
|
|
27
|
-
border-color: var(--color-
|
|
27
|
+
border-color: var(--color-outline-variant);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/* Text content area (left side) */
|
package/assets/reader.css
CHANGED
|
@@ -150,10 +150,11 @@
|
|
|
150
150
|
.ap-card {
|
|
151
151
|
background: var(--color-offset);
|
|
152
152
|
border: var(--border-width-thin) solid var(--color-outline);
|
|
153
|
-
border-left:
|
|
154
|
-
border-radius:
|
|
153
|
+
border-left: 3px solid var(--color-outline);
|
|
154
|
+
border-radius: 8px;
|
|
155
155
|
overflow: hidden;
|
|
156
156
|
padding: var(--space-m);
|
|
157
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
|
157
158
|
transition:
|
|
158
159
|
box-shadow 0.2s ease,
|
|
159
160
|
border-color 0.2s ease;
|
|
@@ -162,6 +163,7 @@
|
|
|
162
163
|
.ap-card:hover {
|
|
163
164
|
border-color: var(--color-outline-variant);
|
|
164
165
|
border-left-color: var(--color-outline-variant);
|
|
166
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
/* ==========================================================================
|
|
@@ -263,9 +265,9 @@
|
|
|
263
265
|
border: var(--border-width-thin) solid var(--color-outline);
|
|
264
266
|
border-radius: 50%;
|
|
265
267
|
flex-shrink: 0;
|
|
266
|
-
height:
|
|
268
|
+
height: 44px;
|
|
267
269
|
object-fit: cover;
|
|
268
|
-
width:
|
|
270
|
+
width: 44px;
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
.ap-card__avatar--default {
|
|
@@ -282,10 +284,12 @@
|
|
|
282
284
|
display: flex;
|
|
283
285
|
flex-direction: column;
|
|
284
286
|
flex: 1;
|
|
287
|
+
gap: 1px;
|
|
285
288
|
min-width: 0;
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
.ap-card__author-name {
|
|
292
|
+
font-size: 0.95em;
|
|
289
293
|
font-weight: 600;
|
|
290
294
|
overflow: hidden;
|
|
291
295
|
text-overflow: ellipsis;
|
|
@@ -301,6 +305,21 @@
|
|
|
301
305
|
text-decoration: underline;
|
|
302
306
|
}
|
|
303
307
|
|
|
308
|
+
.ap-card__bot-badge {
|
|
309
|
+
display: inline-block;
|
|
310
|
+
font-size: 0.6rem;
|
|
311
|
+
font-weight: 700;
|
|
312
|
+
line-height: 1;
|
|
313
|
+
padding: 0.15em 0.35em;
|
|
314
|
+
margin-left: 0.3em;
|
|
315
|
+
border: var(--border-width-thin) solid var(--color-on-offset);
|
|
316
|
+
border-radius: var(--border-radius-small);
|
|
317
|
+
color: var(--color-on-offset);
|
|
318
|
+
vertical-align: middle;
|
|
319
|
+
text-transform: uppercase;
|
|
320
|
+
letter-spacing: 0.03em;
|
|
321
|
+
}
|
|
322
|
+
|
|
304
323
|
.ap-card__author-handle {
|
|
305
324
|
color: var(--color-on-offset);
|
|
306
325
|
font-size: var(--font-size-s);
|
|
@@ -312,12 +331,20 @@
|
|
|
312
331
|
.ap-card__timestamp {
|
|
313
332
|
color: var(--color-on-offset);
|
|
314
333
|
flex-shrink: 0;
|
|
334
|
+
font-size: var(--font-size-s);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.ap-card__edited {
|
|
315
338
|
font-size: var(--font-size-xs);
|
|
339
|
+
margin-left: 0.2em;
|
|
316
340
|
}
|
|
317
341
|
|
|
318
342
|
.ap-card__timestamp-link {
|
|
319
343
|
color: inherit;
|
|
320
344
|
text-decoration: none;
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 0;
|
|
321
348
|
}
|
|
322
349
|
|
|
323
350
|
.ap-card__timestamp-link:hover {
|
|
@@ -351,7 +378,7 @@
|
|
|
351
378
|
|
|
352
379
|
.ap-card__content {
|
|
353
380
|
color: var(--color-on-background);
|
|
354
|
-
line-height:
|
|
381
|
+
line-height: calc(4 / 3 * 1em);
|
|
355
382
|
margin-bottom: var(--space-s);
|
|
356
383
|
overflow-wrap: break-word;
|
|
357
384
|
word-break: break-word;
|
|
@@ -481,7 +508,7 @@
|
|
|
481
508
|
========================================================================== */
|
|
482
509
|
|
|
483
510
|
.ap-card__gallery {
|
|
484
|
-
border-radius:
|
|
511
|
+
border-radius: 6px;
|
|
485
512
|
display: grid;
|
|
486
513
|
gap: 2px;
|
|
487
514
|
margin-bottom: var(--space-s);
|
|
@@ -501,9 +528,14 @@
|
|
|
501
528
|
.ap-card__gallery img {
|
|
502
529
|
background: var(--color-offset-variant);
|
|
503
530
|
display: block;
|
|
504
|
-
height:
|
|
531
|
+
height: 220px;
|
|
505
532
|
object-fit: cover;
|
|
506
533
|
width: 100%;
|
|
534
|
+
transition: filter 0.2s ease;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.ap-card__gallery-link:hover img {
|
|
538
|
+
filter: brightness(0.92);
|
|
507
539
|
}
|
|
508
540
|
|
|
509
541
|
.ap-card__gallery-link--more::after {
|
|
@@ -534,7 +566,7 @@
|
|
|
534
566
|
|
|
535
567
|
.ap-card__gallery--1 img {
|
|
536
568
|
height: auto;
|
|
537
|
-
max-height:
|
|
569
|
+
max-height: 500px;
|
|
538
570
|
}
|
|
539
571
|
|
|
540
572
|
/* 2 photos — side by side */
|
|
@@ -706,6 +738,30 @@
|
|
|
706
738
|
opacity: 0.7;
|
|
707
739
|
}
|
|
708
740
|
|
|
741
|
+
/* Hashtag stuffing collapse */
|
|
742
|
+
.ap-hashtag-overflow {
|
|
743
|
+
margin: var(--space-xs) 0;
|
|
744
|
+
font-size: var(--font-size-s);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.ap-hashtag-overflow summary {
|
|
748
|
+
cursor: pointer;
|
|
749
|
+
color: var(--color-on-offset);
|
|
750
|
+
list-style: none;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.ap-hashtag-overflow summary::before {
|
|
754
|
+
content: "▸ ";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.ap-hashtag-overflow[open] summary::before {
|
|
758
|
+
content: "▾ ";
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.ap-hashtag-overflow p {
|
|
762
|
+
margin-top: var(--space-xs);
|
|
763
|
+
}
|
|
764
|
+
|
|
709
765
|
/* ==========================================================================
|
|
710
766
|
Interaction Buttons
|
|
711
767
|
========================================================================== */
|
|
@@ -714,60 +770,86 @@
|
|
|
714
770
|
border-top: var(--border-width-thin) solid var(--color-outline);
|
|
715
771
|
display: flex;
|
|
716
772
|
flex-wrap: wrap;
|
|
717
|
-
gap:
|
|
773
|
+
gap: 2px;
|
|
718
774
|
padding-top: var(--space-s);
|
|
719
775
|
}
|
|
720
776
|
|
|
721
777
|
.ap-card__action {
|
|
722
778
|
align-items: center;
|
|
723
779
|
background: transparent;
|
|
724
|
-
border:
|
|
725
|
-
border-radius:
|
|
780
|
+
border: 0;
|
|
781
|
+
border-radius: 6px;
|
|
726
782
|
color: var(--color-on-offset);
|
|
727
783
|
cursor: pointer;
|
|
728
784
|
display: inline-flex;
|
|
729
785
|
font-size: var(--font-size-s);
|
|
730
|
-
gap:
|
|
731
|
-
|
|
786
|
+
gap: 0.3em;
|
|
787
|
+
min-height: 36px;
|
|
788
|
+
padding: 0.25em 0.6em;
|
|
732
789
|
text-decoration: none;
|
|
733
|
-
transition:
|
|
790
|
+
transition:
|
|
791
|
+
background-color 0.15s ease,
|
|
792
|
+
color 0.15s ease;
|
|
734
793
|
}
|
|
735
794
|
|
|
736
795
|
.ap-card__action:hover {
|
|
737
796
|
background: var(--color-offset-variant);
|
|
738
|
-
border-color: var(--color-outline-variant);
|
|
739
797
|
color: var(--color-on-background);
|
|
740
798
|
}
|
|
741
799
|
|
|
742
|
-
/*
|
|
800
|
+
/* Color-coded hover states per action type */
|
|
801
|
+
.ap-card__action--reply:hover {
|
|
802
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
803
|
+
color: var(--color-primary);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.ap-card__action--boost:hover {
|
|
807
|
+
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
|
|
808
|
+
color: var(--color-green50);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.ap-card__action--like:hover {
|
|
812
|
+
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
|
|
813
|
+
color: var(--color-red45);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.ap-card__action--link:hover {
|
|
817
|
+
background: var(--color-offset-variant);
|
|
818
|
+
color: var(--color-on-background);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
.ap-card__action--save:hover {
|
|
822
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
823
|
+
color: var(--color-primary);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/* Active interaction states */
|
|
743
827
|
.ap-card__action--like.ap-card__action--active {
|
|
744
|
-
background: var(--color-
|
|
745
|
-
border-color: var(--color-red45);
|
|
828
|
+
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
|
|
746
829
|
color: var(--color-red45);
|
|
747
830
|
}
|
|
748
831
|
|
|
749
832
|
.ap-card__action--boost.ap-card__action--active {
|
|
750
|
-
background: var(--color-
|
|
751
|
-
border-color: var(--color-green50);
|
|
833
|
+
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
|
|
752
834
|
color: var(--color-green50);
|
|
753
835
|
}
|
|
754
836
|
|
|
755
837
|
.ap-card__action--save.ap-card__action--active {
|
|
756
|
-
background:
|
|
757
|
-
|
|
758
|
-
color: #4a9eff;
|
|
838
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
839
|
+
color: var(--color-primary);
|
|
759
840
|
}
|
|
760
841
|
|
|
761
842
|
.ap-card__action:disabled {
|
|
762
843
|
cursor: wait;
|
|
763
|
-
opacity: 0.
|
|
844
|
+
opacity: 0.5;
|
|
764
845
|
}
|
|
765
846
|
|
|
766
847
|
/* Interaction counts */
|
|
767
848
|
.ap-card__count {
|
|
768
849
|
font-size: var(--font-size-xs);
|
|
769
|
-
color:
|
|
770
|
-
|
|
850
|
+
color: inherit;
|
|
851
|
+
opacity: 0.7;
|
|
852
|
+
margin-left: 0.1em;
|
|
771
853
|
font-variant-numeric: tabular-nums;
|
|
772
854
|
}
|
|
773
855
|
|
|
@@ -2489,9 +2571,14 @@
|
|
|
2489
2571
|
|
|
2490
2572
|
.ap-quote-embed {
|
|
2491
2573
|
border: var(--border-width-thin) solid var(--color-outline);
|
|
2492
|
-
border-radius:
|
|
2574
|
+
border-radius: 8px;
|
|
2493
2575
|
margin-top: var(--space-s);
|
|
2494
2576
|
overflow: hidden;
|
|
2577
|
+
transition: border-color 0.15s ease;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
.ap-quote-embed:hover {
|
|
2581
|
+
border-color: var(--color-outline-variant);
|
|
2495
2582
|
}
|
|
2496
2583
|
|
|
2497
2584
|
.ap-quote-embed--pending {
|
|
@@ -2506,7 +2593,7 @@
|
|
|
2506
2593
|
}
|
|
2507
2594
|
|
|
2508
2595
|
.ap-quote-embed__link:hover {
|
|
2509
|
-
background: var(--color-offset);
|
|
2596
|
+
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
|
|
2510
2597
|
}
|
|
2511
2598
|
|
|
2512
2599
|
.ap-quote-embed__author {
|
|
@@ -2571,8 +2658,8 @@
|
|
|
2571
2658
|
.ap-quote-embed__content {
|
|
2572
2659
|
color: var(--color-on-background);
|
|
2573
2660
|
font-size: var(--font-size-s);
|
|
2574
|
-
line-height:
|
|
2575
|
-
max-height: calc(1.
|
|
2661
|
+
line-height: calc(4 / 3 * 1em);
|
|
2662
|
+
max-height: calc(1.333em * 6);
|
|
2576
2663
|
overflow: hidden;
|
|
2577
2664
|
}
|
|
2578
2665
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content post-processing utilities.
|
|
3
|
+
* Applied after sanitization and emoji replacement in the item pipeline.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shorten displayed URLs in <a> tags that exceed maxLength.
|
|
8
|
+
* Keeps the full URL in href, only truncates the visible text.
|
|
9
|
+
*
|
|
10
|
+
* Example: <a href="https://example.com/very/long/path">https://example.com/very/long/path</a>
|
|
11
|
+
* → <a href="https://example.com/very/long/path" title="https://example.com/very/long/path">example.com/very/lon…</a>
|
|
12
|
+
*
|
|
13
|
+
* @param {string} html - Sanitized HTML content
|
|
14
|
+
* @param {number} [maxLength=30] - Max visible URL length before truncation
|
|
15
|
+
* @returns {string} HTML with shortened display URLs
|
|
16
|
+
*/
|
|
17
|
+
export function shortenDisplayUrls(html, maxLength = 30) {
|
|
18
|
+
if (!html) return html;
|
|
19
|
+
|
|
20
|
+
// Match <a ...>URL text</a> where the visible text looks like a URL
|
|
21
|
+
return html.replace(
|
|
22
|
+
/(<a\s[^>]*>)(https?:\/\/[^<]+)(<\/a>)/gi,
|
|
23
|
+
(match, openTag, urlText, closeTag) => {
|
|
24
|
+
if (urlText.length <= maxLength) return match;
|
|
25
|
+
|
|
26
|
+
// Strip protocol for display
|
|
27
|
+
const display = urlText.replace(/^https?:\/\//, "");
|
|
28
|
+
const truncated = display.slice(0, maxLength - 1) + "\u2026";
|
|
29
|
+
|
|
30
|
+
// Add title attribute with full URL for hover tooltip (if not already present)
|
|
31
|
+
let tag = openTag;
|
|
32
|
+
if (!tag.includes("title=")) {
|
|
33
|
+
tag = tag.replace(/>$/, ` title="${urlText}">`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return `${tag}${truncated}${closeTag}`;
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Collapse paragraphs that are mostly hashtag links (hashtag stuffing).
|
|
43
|
+
* Detects <p> blocks where 80%+ of the text content is hashtag links
|
|
44
|
+
* and wraps them in a <details> element.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} html - Sanitized HTML content
|
|
47
|
+
* @param {number} [minTags=3] - Minimum number of hashtag links to trigger collapse
|
|
48
|
+
* @returns {string} HTML with hashtag-heavy paragraphs collapsed
|
|
49
|
+
*/
|
|
50
|
+
export function collapseHashtagStuffing(html, minTags = 3) {
|
|
51
|
+
if (!html) return html;
|
|
52
|
+
|
|
53
|
+
// Match <p> blocks
|
|
54
|
+
return html.replace(/<p>([^]*?)<\/p>/gi, (match, inner) => {
|
|
55
|
+
// Count hashtag links: <a ...>#something</a> or plain #word
|
|
56
|
+
const hashtagLinks = inner.match(/<a[^>]*>#[^<]+<\/a>/gi) || [];
|
|
57
|
+
if (hashtagLinks.length < minTags) return match;
|
|
58
|
+
|
|
59
|
+
// Calculate what fraction of text content is hashtags
|
|
60
|
+
const textOnly = inner.replace(/<[^>]*>/g, "").trim();
|
|
61
|
+
const hashtagText = hashtagLinks
|
|
62
|
+
.map((link) => link.replace(/<[^>]*>/g, "").trim())
|
|
63
|
+
.join(" ");
|
|
64
|
+
|
|
65
|
+
// If hashtags make up 80%+ of the text content, collapse
|
|
66
|
+
if (hashtagText.length / Math.max(textOnly.length, 1) >= 0.8) {
|
|
67
|
+
return `<details class="ap-hashtag-overflow"><summary>Show ${hashtagLinks.length} tags</summary><p>${inner}</p></details>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return match;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -119,12 +119,14 @@ export function mapMastodonStatusToItem(status, instance) {
|
|
|
119
119
|
summary: status.spoiler_text || "",
|
|
120
120
|
sensitive: status.sensitive || false,
|
|
121
121
|
published: status.created_at || new Date().toISOString(),
|
|
122
|
+
updated: status.edited_at || "",
|
|
122
123
|
author: {
|
|
123
124
|
name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
|
|
124
125
|
url: account.url || "",
|
|
125
126
|
photo: account.avatar || account.avatar_static || "",
|
|
126
127
|
handle,
|
|
127
128
|
emojis: authorEmojis,
|
|
129
|
+
bot: account.bot || false,
|
|
128
130
|
},
|
|
129
131
|
category,
|
|
130
132
|
mentions,
|
package/lib/item-processing.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { stripQuoteReferenceHtml } from "./og-unfurl.js";
|
|
10
10
|
import { replaceCustomEmoji } from "./emoji-utils.js";
|
|
11
|
+
import { shortenDisplayUrls, collapseHashtagStuffing } from "./content-utils.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Post-process timeline items for rendering.
|
|
@@ -31,7 +32,10 @@ export async function postProcessItems(items, options = {}) {
|
|
|
31
32
|
// 3. Replace custom emoji shortcodes with <img> tags
|
|
32
33
|
applyCustomEmoji(items);
|
|
33
34
|
|
|
34
|
-
// 4.
|
|
35
|
+
// 4. Shorten long URLs and collapse hashtag stuffing in content
|
|
36
|
+
applyContentEnhancements(items);
|
|
37
|
+
|
|
38
|
+
// 5. Build interaction map (likes/boosts) — empty when no collection
|
|
35
39
|
const interactionMap = options.interactionsCol
|
|
36
40
|
? await buildInteractionMap(items, options.interactionsCol)
|
|
37
41
|
: {};
|
|
@@ -154,6 +158,24 @@ function applyCustomEmoji(items) {
|
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Shorten long URLs and collapse hashtag-heavy paragraphs in content.
|
|
163
|
+
* Mutates items in place.
|
|
164
|
+
*
|
|
165
|
+
* @param {Array} items
|
|
166
|
+
*/
|
|
167
|
+
function applyContentEnhancements(items) {
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
if (item.content?.html) {
|
|
170
|
+
item.content.html = shortenDisplayUrls(item.content.html);
|
|
171
|
+
item.content.html = collapseHashtagStuffing(item.content.html);
|
|
172
|
+
}
|
|
173
|
+
if (item.quote?.content?.html) {
|
|
174
|
+
item.quote.content.html = shortenDisplayUrls(item.quote.content.html);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
157
179
|
/**
|
|
158
180
|
* Build interaction map (likes/boosts) for template rendering.
|
|
159
181
|
* Returns { [uid]: { like: true, boost: true } }.
|
package/lib/timeline-store.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @module timeline-store
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab";
|
|
6
|
+
import { Article, Application, Emoji, Hashtag, Mention, Service } from "@fedify/fedify/vocab";
|
|
7
7
|
import sanitizeHtml from "sanitize-html";
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -101,7 +101,10 @@ export async function extractActorInfo(actor, options = {}) {
|
|
|
101
101
|
// Emoji extraction failed — non-critical
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
// Bot detection — Service and Application actors are automated accounts
|
|
105
|
+
const bot = actor instanceof Service || actor instanceof Application;
|
|
106
|
+
|
|
107
|
+
return { name, url, photo, handle, emojis, bot };
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
/**
|
|
@@ -154,6 +157,9 @@ export async function extractObjectData(object, options = {}) {
|
|
|
154
157
|
? String(object.published)
|
|
155
158
|
: new Date().toISOString();
|
|
156
159
|
|
|
160
|
+
// Edited date — non-null when the post has been updated after publishing
|
|
161
|
+
const updated = object.updated ? String(object.updated) : "";
|
|
162
|
+
|
|
157
163
|
// Extract author — try multiple strategies in order of reliability
|
|
158
164
|
const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
|
|
159
165
|
let authorObj = null;
|
|
@@ -304,6 +310,7 @@ export async function extractObjectData(object, options = {}) {
|
|
|
304
310
|
summary,
|
|
305
311
|
sensitive,
|
|
306
312
|
published,
|
|
313
|
+
updated,
|
|
307
314
|
author,
|
|
308
315
|
category,
|
|
309
316
|
mentions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
{# AP link interception for internal navigation #}
|
|
21
21
|
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.js"></script>
|
|
22
|
+
{# Blurhash placeholder backgrounds for gallery images #}
|
|
23
|
+
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-blurhash.js"></script>
|
|
22
24
|
|
|
23
25
|
{% if readerParent %}
|
|
24
26
|
<nav class="ap-breadcrumb" aria-label="Breadcrumb">
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
{% else %}
|
|
54
54
|
<span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
|
|
55
55
|
{% endif %}
|
|
56
|
+
{% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
|
|
56
57
|
</div>
|
|
57
58
|
{% if item.author.handle %}
|
|
58
59
|
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
|
|
64
65
|
{{ item.published | date("PPp") }}
|
|
65
66
|
</time>
|
|
67
|
+
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
|
|
66
68
|
</a>
|
|
67
69
|
{% endif %}
|
|
68
70
|
</header>
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
{# Support both old string format and new object format #}
|
|
11
11
|
{% set photoSrc = photo.url if photo.url else photo %}
|
|
12
12
|
{% set photoAlt = photo.alt if photo.alt else "" %}
|
|
13
|
+
{% set photoBlurhash = photo.blurhash if photo.blurhash else "" %}
|
|
14
|
+
{# Focus-point cropping: convert -1..1 range to CSS object-position percentages #}
|
|
15
|
+
{% set focusStyle = "" %}
|
|
16
|
+
{% if photo.focus and photo.focus.x != null and photo.focus.y != null %}
|
|
17
|
+
{% set fpX = ((photo.focus.x + 1) / 2 * 100) %}
|
|
18
|
+
{% set fpY = ((1 - (photo.focus.y + 1) / 2) * 100) %}
|
|
19
|
+
{% set focusStyle = "object-position:" + fpX + "% " + fpY + "%;" %}
|
|
20
|
+
{% endif %}
|
|
13
21
|
{% if loop.index0 < 4 %}
|
|
14
22
|
<div class="ap-card__gallery-item" x-data="{ showAlt: false }">
|
|
15
23
|
<button type="button" @click="idx = {{ loop.index0 }}; lightbox = true" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
|
|
16
|
-
<img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy">
|
|
24
|
+
<img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy"{% if focusStyle %} style="{{ focusStyle }}"{% endif %}{% if photoBlurhash %} data-blurhash="{{ photoBlurhash }}"{% endif %}>
|
|
17
25
|
{% if loop.index0 == 3 and extraCount > 0 %}
|
|
18
26
|
<span class="ap-card__gallery-more">+{{ extraCount }}</span>
|
|
19
27
|
{% endif %}
|
|
@@ -26,8 +26,14 @@
|
|
|
26
26
|
{% endif %}
|
|
27
27
|
{% if item.quote.photo and item.quote.photo.length > 0 %}
|
|
28
28
|
{% set qPhoto = item.quote.photo[0] %}
|
|
29
|
+
{% set qFocusStyle = "" %}
|
|
30
|
+
{% if qPhoto.focus and qPhoto.focus.x != null and qPhoto.focus.y != null %}
|
|
31
|
+
{% set qFpX = ((qPhoto.focus.x + 1) / 2 * 100) %}
|
|
32
|
+
{% set qFpY = ((1 - (qPhoto.focus.y + 1) / 2) * 100) %}
|
|
33
|
+
{% set qFocusStyle = "object-position:" + qFpX + "% " + qFpY + "%;" %}
|
|
34
|
+
{% endif %}
|
|
29
35
|
<div class="ap-quote-embed__media">
|
|
30
|
-
<img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo">
|
|
36
|
+
<img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo"{% if qFocusStyle %} style="{{ qFocusStyle }}"{% endif %}>
|
|
31
37
|
</div>
|
|
32
38
|
{% endif %}
|
|
33
39
|
</a>
|