@rmdes/indiekit-endpoint-microsub 1.0.9 → 1.0.11
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/styles.css +37 -0
- package/lib/controllers/reader.js +77 -6
- package/lib/storage/items.js +24 -0
- package/locales/en.json +8 -1
- package/package.json +1 -1
- package/views/channel.njk +19 -0
- package/views/compose.njk +26 -2
package/assets/styles.css
CHANGED
|
@@ -679,6 +679,43 @@
|
|
|
679
679
|
text-align: right;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
+
.compose__syndication {
|
|
683
|
+
background: var(--color-offset);
|
|
684
|
+
border: none;
|
|
685
|
+
border-radius: var(--border-radius);
|
|
686
|
+
margin: var(--space-m) 0;
|
|
687
|
+
padding: var(--space-m);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.compose__syndication legend {
|
|
691
|
+
font-weight: 600;
|
|
692
|
+
margin-bottom: var(--space-xs);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.compose__syndication .hint {
|
|
696
|
+
color: var(--color-text-muted);
|
|
697
|
+
font-size: var(--font-size-small);
|
|
698
|
+
margin-bottom: var(--space-s);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.syndication-target {
|
|
702
|
+
align-items: center;
|
|
703
|
+
cursor: pointer;
|
|
704
|
+
display: flex;
|
|
705
|
+
gap: var(--space-s);
|
|
706
|
+
padding: var(--space-xs) 0;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
.syndication-target input[type="checkbox"] {
|
|
710
|
+
flex-shrink: 0;
|
|
711
|
+
height: 1.25rem;
|
|
712
|
+
width: 1.25rem;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.syndication-target__name {
|
|
716
|
+
flex: 1;
|
|
717
|
+
}
|
|
718
|
+
|
|
682
719
|
/* ==========================================================================
|
|
683
720
|
Settings
|
|
684
721
|
========================================================================== */
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getTimelineItems,
|
|
22
22
|
getItemById,
|
|
23
23
|
markItemsRead,
|
|
24
|
+
countReadItems,
|
|
24
25
|
} from "../storage/items.js";
|
|
25
26
|
import { getUserId } from "../utils/auth.js";
|
|
26
27
|
import {
|
|
@@ -95,24 +96,37 @@ export async function channel(request, response) {
|
|
|
95
96
|
const { application } = request.app.locals;
|
|
96
97
|
const userId = getUserId(request);
|
|
97
98
|
const { uid } = request.params;
|
|
98
|
-
const { before, after } = request.query;
|
|
99
|
+
const { before, after, showRead } = request.query;
|
|
99
100
|
|
|
100
101
|
const channelDocument = await getChannel(application, uid, userId);
|
|
101
102
|
if (!channelDocument) {
|
|
102
103
|
return response.status(404).render("404");
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// Check if showing read items
|
|
107
|
+
const showReadItems = showRead === "true";
|
|
108
|
+
|
|
105
109
|
const timeline = await getTimelineItems(application, channelDocument._id, {
|
|
106
110
|
before,
|
|
107
111
|
after,
|
|
108
112
|
userId,
|
|
113
|
+
showRead: showReadItems,
|
|
109
114
|
});
|
|
110
115
|
|
|
116
|
+
// Count read items to show "View read items" button
|
|
117
|
+
const readCount = await countReadItems(
|
|
118
|
+
application,
|
|
119
|
+
channelDocument._id,
|
|
120
|
+
userId,
|
|
121
|
+
);
|
|
122
|
+
|
|
111
123
|
response.render("channel", {
|
|
112
124
|
title: channelDocument.name,
|
|
113
125
|
channel: channelDocument,
|
|
114
126
|
items: timeline.items,
|
|
115
127
|
paging: timeline.paging,
|
|
128
|
+
readCount,
|
|
129
|
+
showRead: showReadItems,
|
|
116
130
|
baseUrl: request.baseUrl,
|
|
117
131
|
});
|
|
118
132
|
}
|
|
@@ -346,6 +360,38 @@ function ensureString(value) {
|
|
|
346
360
|
return String(value);
|
|
347
361
|
}
|
|
348
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Fetch syndication targets from Micropub config
|
|
365
|
+
* @param {object} application - Indiekit application
|
|
366
|
+
* @param {string} token - Auth token
|
|
367
|
+
* @returns {Promise<Array>} Syndication targets
|
|
368
|
+
*/
|
|
369
|
+
async function getSyndicationTargets(application, token) {
|
|
370
|
+
try {
|
|
371
|
+
const micropubEndpoint = application.micropubEndpoint;
|
|
372
|
+
if (!micropubEndpoint) return [];
|
|
373
|
+
|
|
374
|
+
const micropubUrl = micropubEndpoint.startsWith("http")
|
|
375
|
+
? micropubEndpoint
|
|
376
|
+
: new URL(micropubEndpoint, application.url).href;
|
|
377
|
+
|
|
378
|
+
const configUrl = `${micropubUrl}?q=config`;
|
|
379
|
+
const configResponse = await fetch(configUrl, {
|
|
380
|
+
headers: {
|
|
381
|
+
Authorization: `Bearer ${token}`,
|
|
382
|
+
Accept: "application/json",
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (!configResponse.ok) return [];
|
|
387
|
+
|
|
388
|
+
const config = await configResponse.json();
|
|
389
|
+
return config["syndicate-to"] || [];
|
|
390
|
+
} catch {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
349
395
|
/**
|
|
350
396
|
* Compose response form
|
|
351
397
|
* @param {object} request - Express request
|
|
@@ -353,6 +399,8 @@ function ensureString(value) {
|
|
|
353
399
|
* @returns {Promise<void>}
|
|
354
400
|
*/
|
|
355
401
|
export async function compose(request, response) {
|
|
402
|
+
const { application } = request.app.locals;
|
|
403
|
+
|
|
356
404
|
// Support both long-form (replyTo) and short-form (reply) query params
|
|
357
405
|
const {
|
|
358
406
|
replyTo,
|
|
@@ -365,12 +413,19 @@ export async function compose(request, response) {
|
|
|
365
413
|
bookmark,
|
|
366
414
|
} = request.query;
|
|
367
415
|
|
|
416
|
+
// Fetch syndication targets if user is authenticated
|
|
417
|
+
const token = request.session?.access_token;
|
|
418
|
+
const syndicationTargets = token
|
|
419
|
+
? await getSyndicationTargets(application, token)
|
|
420
|
+
: [];
|
|
421
|
+
|
|
368
422
|
response.render("compose", {
|
|
369
423
|
title: request.__("microsub.compose.title"),
|
|
370
424
|
replyTo: ensureString(replyTo || reply),
|
|
371
425
|
likeOf: ensureString(likeOf || like),
|
|
372
426
|
repostOf: ensureString(repostOf || repost),
|
|
373
427
|
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
|
428
|
+
syndicationTargets,
|
|
374
429
|
baseUrl: request.baseUrl,
|
|
375
430
|
});
|
|
376
431
|
}
|
|
@@ -388,6 +443,7 @@ export async function submitCompose(request, response) {
|
|
|
388
443
|
const likeOf = request.body["like-of"];
|
|
389
444
|
const repostOf = request.body["repost-of"];
|
|
390
445
|
const bookmarkOf = request.body["bookmark-of"];
|
|
446
|
+
const syndicateTo = request.body["mp-syndicate-to"];
|
|
391
447
|
|
|
392
448
|
// Debug logging
|
|
393
449
|
console.info(
|
|
@@ -400,6 +456,7 @@ export async function submitCompose(request, response) {
|
|
|
400
456
|
likeOf,
|
|
401
457
|
repostOf,
|
|
402
458
|
bookmarkOf,
|
|
459
|
+
syndicateTo,
|
|
403
460
|
});
|
|
404
461
|
|
|
405
462
|
// Get Micropub endpoint
|
|
@@ -427,16 +484,22 @@ export async function submitCompose(request, response) {
|
|
|
427
484
|
micropubData.append("h", "entry");
|
|
428
485
|
|
|
429
486
|
if (likeOf) {
|
|
430
|
-
// Like post
|
|
487
|
+
// Like post - content is optional comment
|
|
431
488
|
micropubData.append("like-of", likeOf);
|
|
489
|
+
if (content && content.trim()) {
|
|
490
|
+
micropubData.append("content", content.trim());
|
|
491
|
+
}
|
|
432
492
|
} else if (repostOf) {
|
|
433
|
-
// Repost
|
|
493
|
+
// Repost - content is optional comment
|
|
434
494
|
micropubData.append("repost-of", repostOf);
|
|
495
|
+
if (content && content.trim()) {
|
|
496
|
+
micropubData.append("content", content.trim());
|
|
497
|
+
}
|
|
435
498
|
} else if (bookmarkOf) {
|
|
436
|
-
// Bookmark
|
|
499
|
+
// Bookmark - content is optional comment
|
|
437
500
|
micropubData.append("bookmark-of", bookmarkOf);
|
|
438
|
-
if (content) {
|
|
439
|
-
micropubData.append("content", content);
|
|
501
|
+
if (content && content.trim()) {
|
|
502
|
+
micropubData.append("content", content.trim());
|
|
440
503
|
}
|
|
441
504
|
} else if (inReplyTo) {
|
|
442
505
|
// Reply
|
|
@@ -447,6 +510,14 @@ export async function submitCompose(request, response) {
|
|
|
447
510
|
micropubData.append("content", content || "");
|
|
448
511
|
}
|
|
449
512
|
|
|
513
|
+
// Add syndication targets
|
|
514
|
+
if (syndicateTo) {
|
|
515
|
+
const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];
|
|
516
|
+
for (const target of targets) {
|
|
517
|
+
micropubData.append("mp-syndicate-to", target);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
450
521
|
// Debug: log what we're sending
|
|
451
522
|
console.info("[Microsub] Sending to Micropub:", {
|
|
452
523
|
url: micropubUrl,
|
package/lib/storage/items.js
CHANGED
|
@@ -78,6 +78,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
|
|
|
78
78
|
* @param {string} [options.after] - After cursor
|
|
79
79
|
* @param {number} [options.limit] - Items per page
|
|
80
80
|
* @param {string} [options.userId] - User ID for read state
|
|
81
|
+
* @param {boolean} [options.showRead] - Whether to show read items (default: false)
|
|
81
82
|
* @returns {Promise<object>} Timeline with items and paging
|
|
82
83
|
*/
|
|
83
84
|
export async function getTimelineItems(application, channelId, options = {}) {
|
|
@@ -86,7 +87,12 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
|
|
86
87
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
87
88
|
const limit = parseLimit(options.limit);
|
|
88
89
|
|
|
90
|
+
// Base query - filter out read items unless showRead is true
|
|
89
91
|
const baseQuery = { channelId: objectId };
|
|
92
|
+
if (options.userId && !options.showRead) {
|
|
93
|
+
baseQuery.readBy = { $ne: options.userId };
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
const query = buildPaginationQuery({
|
|
91
97
|
before: options.before,
|
|
92
98
|
after: options.after,
|
|
@@ -256,6 +262,24 @@ export async function getItemsByUids(application, uids, userId) {
|
|
|
256
262
|
return items.map((item) => transformToJf2(item, userId));
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Count read items in a channel for a user
|
|
267
|
+
* @param {object} application - Indiekit application
|
|
268
|
+
* @param {ObjectId|string} channelId - Channel ObjectId
|
|
269
|
+
* @param {string} userId - User ID
|
|
270
|
+
* @returns {Promise<number>} Count of read items
|
|
271
|
+
*/
|
|
272
|
+
export async function countReadItems(application, channelId, userId) {
|
|
273
|
+
const collection = getCollection(application);
|
|
274
|
+
const objectId =
|
|
275
|
+
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
|
276
|
+
|
|
277
|
+
return collection.countDocuments({
|
|
278
|
+
channelId: objectId,
|
|
279
|
+
readBy: userId,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
259
283
|
/**
|
|
260
284
|
* Mark items as read
|
|
261
285
|
* @param {object} application - Indiekit application
|
package/locales/en.json
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
"title": "Reader",
|
|
5
5
|
"empty": "No items to display",
|
|
6
6
|
"markAllRead": "Mark all as read",
|
|
7
|
+
"showRead": "Show read ({{count}})",
|
|
8
|
+
"hideRead": "Hide read items",
|
|
9
|
+
"allRead": "All caught up!",
|
|
7
10
|
"newer": "Newer",
|
|
8
11
|
"older": "Older"
|
|
9
12
|
},
|
|
@@ -43,12 +46,16 @@
|
|
|
43
46
|
"compose": {
|
|
44
47
|
"title": "Compose",
|
|
45
48
|
"content": "What's on your mind?",
|
|
49
|
+
"comment": "Add a comment (optional)",
|
|
50
|
+
"commentHint": "Your comment will be included with the syndicated post",
|
|
46
51
|
"submit": "Post",
|
|
47
52
|
"cancel": "Cancel",
|
|
48
53
|
"replyTo": "Replying to",
|
|
49
54
|
"likeOf": "Liking",
|
|
50
55
|
"repostOf": "Reposting",
|
|
51
|
-
"bookmarkOf": "Bookmarking"
|
|
56
|
+
"bookmarkOf": "Bookmarking",
|
|
57
|
+
"syndicateTo": "Syndicate to",
|
|
58
|
+
"syndicateHint": "Also share this to your connected accounts"
|
|
52
59
|
},
|
|
53
60
|
"settings": {
|
|
54
61
|
"title": "{{channel}} settings",
|
package/package.json
CHANGED
package/views/channel.njk
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
|
8
8
|
</a>
|
|
9
9
|
<div class="channel__actions">
|
|
10
|
+
{% if not showRead and items.length > 0 %}
|
|
10
11
|
<form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
|
|
11
12
|
<input type="hidden" name="channel" value="{{ channel.uid }}">
|
|
12
13
|
<input type="hidden" name="entry" value="last-read-entry">
|
|
@@ -14,6 +15,16 @@
|
|
|
14
15
|
{{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
|
|
15
16
|
</button>
|
|
16
17
|
</form>
|
|
18
|
+
{% endif %}
|
|
19
|
+
{% if showRead %}
|
|
20
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary button--small">
|
|
21
|
+
{{ icon("hide") }} {{ __("microsub.reader.hideRead") }}
|
|
22
|
+
</a>
|
|
23
|
+
{% elif readCount > 0 %}
|
|
24
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary button--small">
|
|
25
|
+
{{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
|
|
26
|
+
</a>
|
|
27
|
+
{% endif %}
|
|
17
28
|
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
|
|
18
29
|
{{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
|
|
19
30
|
</a>
|
|
@@ -48,11 +59,19 @@
|
|
|
48
59
|
{% endif %}
|
|
49
60
|
{% else %}
|
|
50
61
|
<div class="reader__empty">
|
|
62
|
+
{% if readCount > 0 and not showRead %}
|
|
63
|
+
{{ icon("checkboxChecked") }}
|
|
64
|
+
<p>{{ __("microsub.reader.allRead") }}</p>
|
|
65
|
+
<a href="{{ baseUrl }}/channels/{{ channel.uid }}?showRead=true" class="button button--secondary">
|
|
66
|
+
{{ icon("show") }} {{ __("microsub.reader.showRead", { count: readCount }) }}
|
|
67
|
+
</a>
|
|
68
|
+
{% else %}
|
|
51
69
|
{{ icon("syndicate") }}
|
|
52
70
|
<p>{{ __("microsub.timeline.empty") }}</p>
|
|
53
71
|
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary">
|
|
54
72
|
{{ __("microsub.feeds.subscribe") }}
|
|
55
73
|
</a>
|
|
74
|
+
{% endif %}
|
|
56
75
|
</div>
|
|
57
76
|
{% endif %}
|
|
58
77
|
</div>
|
package/views/compose.njk
CHANGED
|
@@ -71,6 +71,32 @@
|
|
|
71
71
|
<div class="compose__counter">
|
|
72
72
|
<span id="char-count">0</span> characters
|
|
73
73
|
</div>
|
|
74
|
+
{% else %}
|
|
75
|
+
{# Comment field for likes/reposts/bookmarks #}
|
|
76
|
+
{{ textarea({
|
|
77
|
+
label: __("microsub.compose.comment"),
|
|
78
|
+
id: "content",
|
|
79
|
+
name: "content",
|
|
80
|
+
rows: 3,
|
|
81
|
+
hint: __("microsub.compose.commentHint")
|
|
82
|
+
}) }}
|
|
83
|
+
<div class="compose__counter">
|
|
84
|
+
<span id="char-count">0</span> characters
|
|
85
|
+
</div>
|
|
86
|
+
{% endif %}
|
|
87
|
+
|
|
88
|
+
{# Syndication targets #}
|
|
89
|
+
{% if syndicationTargets and syndicationTargets.length %}
|
|
90
|
+
<fieldset class="compose__syndication">
|
|
91
|
+
<legend>{{ __("microsub.compose.syndicateTo") }}</legend>
|
|
92
|
+
<p class="hint">{{ __("microsub.compose.syndicateHint") }}</p>
|
|
93
|
+
{% for target in syndicationTargets %}
|
|
94
|
+
<label class="syndication-target">
|
|
95
|
+
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}"{% if target.checked %} checked{% endif %}>
|
|
96
|
+
<span class="syndication-target__name">{{ target.name }}</span>
|
|
97
|
+
</label>
|
|
98
|
+
{% endfor %}
|
|
99
|
+
</fieldset>
|
|
74
100
|
{% endif %}
|
|
75
101
|
|
|
76
102
|
<div class="button-group">
|
|
@@ -84,7 +110,6 @@
|
|
|
84
110
|
</form>
|
|
85
111
|
</div>
|
|
86
112
|
|
|
87
|
-
{% if not isAction %}
|
|
88
113
|
<script type="module">
|
|
89
114
|
const textarea = document.getElementById('content');
|
|
90
115
|
const counter = document.getElementById('char-count');
|
|
@@ -94,5 +119,4 @@
|
|
|
94
119
|
});
|
|
95
120
|
}
|
|
96
121
|
</script>
|
|
97
|
-
{% endif %}
|
|
98
122
|
{% endblock %}
|