@rmdes/indiekit-endpoint-conversations 1.0.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/index.js ADDED
@@ -0,0 +1,117 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import express from "express";
5
+
6
+ import { conversationsController } from "./lib/controllers/conversations.js";
7
+ import { createIndexes } from "./lib/storage/conversation-items.js";
8
+
9
+ const defaults = {
10
+ mountPath: "/conversations",
11
+ directPolling: {
12
+ mastodon: false,
13
+ bluesky: false,
14
+ },
15
+ useGranary: false,
16
+ granaryUrl: "https://granary.io",
17
+ };
18
+
19
+ const router = express.Router();
20
+
21
+ export default class ConversationsEndpoint {
22
+ name = "Conversations endpoint";
23
+
24
+ /**
25
+ * @param {object} options - Plugin options
26
+ * @param {string} [options.mountPath] - Path to mount endpoint
27
+ * @param {object} [options.directPolling] - Enable direct API polling
28
+ * @param {boolean} [options.useGranary] - Use Granary REST API for format conversion
29
+ * @param {string} [options.granaryUrl] - Custom Granary instance URL
30
+ */
31
+ constructor(options = {}) {
32
+ this.options = { ...defaults, ...options };
33
+ this.mountPath = this.options.mountPath;
34
+ }
35
+
36
+ get localesDirectory() {
37
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "locales");
38
+ }
39
+
40
+ get navigationItems() {
41
+ return {
42
+ href: this.options.mountPath,
43
+ text: "conversations.title",
44
+ requiresDatabase: true,
45
+ };
46
+ }
47
+
48
+ get routes() {
49
+ // Admin UI
50
+ router.get("/", conversationsController.list);
51
+ router.get("/post", conversationsController.detail);
52
+
53
+ // JSON API (public-ish, for Eleventy client-side fetch)
54
+ router.get("/api/post", conversationsController.apiPost);
55
+
56
+ // Webmention ingestion endpoint
57
+ router.post("/ingest", conversationsController.ingest);
58
+
59
+ return router;
60
+ }
61
+
62
+ get routesPublic() {
63
+ const publicRouter = express.Router();
64
+
65
+ // JSON API must be public for Eleventy client-side JS to fetch
66
+ publicRouter.get("/api/post", conversationsController.apiPost);
67
+
68
+ // Webmention ingestion can be called by Bridgy or webmention.io
69
+ publicRouter.post("/ingest", conversationsController.ingest);
70
+
71
+ return publicRouter;
72
+ }
73
+
74
+ init(indiekit) {
75
+ console.info("[Conversations] Initializing endpoint-conversations plugin");
76
+
77
+ // Register MongoDB collections
78
+ indiekit.addCollection("conversation_items");
79
+ indiekit.addCollection("conversation_state");
80
+
81
+ console.info("[Conversations] Registered MongoDB collections");
82
+
83
+ indiekit.addEndpoint(this);
84
+
85
+ // Store options on the application for access by controllers
86
+ if (!indiekit.config.application.conversations) {
87
+ indiekit.config.application.conversations = this.options;
88
+ }
89
+
90
+ if (indiekit.database) {
91
+ // Create indexes
92
+ createIndexes(indiekit).catch((error) => {
93
+ console.warn(
94
+ "[Conversations] Index creation failed:",
95
+ error.message,
96
+ );
97
+ });
98
+
99
+ // Start direct polling if enabled
100
+ if (
101
+ this.options.directPolling.mastodon ||
102
+ this.options.directPolling.bluesky
103
+ ) {
104
+ import("./lib/polling/scheduler.js")
105
+ .then(({ startPolling }) => {
106
+ startPolling(indiekit, this.options);
107
+ })
108
+ .catch((error) => {
109
+ console.error(
110
+ "[Conversations] Polling scheduler failed to start:",
111
+ error.message,
112
+ );
113
+ });
114
+ }
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Conversations controller
3
+ * Admin UI + JSON API for unified conversation views
4
+ * @module controllers/conversations
5
+ */
6
+
7
+ import {
8
+ classifyWebmention,
9
+ generatePlatformId,
10
+ } from "../ingestion/webmention-classifier.js";
11
+ import { resolveCanonicalUrl } from "../matching/syndication-map.js";
12
+ import {
13
+ getConversationItems,
14
+ getConversationSummaries,
15
+ upsertConversationItem,
16
+ } from "../storage/conversation-items.js";
17
+
18
+ /**
19
+ * List conversations (admin UI)
20
+ * GET /conversations
21
+ */
22
+ async function list(request, response) {
23
+ const { application } = request.app.locals;
24
+ const page = Number.parseInt(request.query.page) || 1;
25
+ const limit = 50;
26
+ const skip = (page - 1) * limit;
27
+
28
+ try {
29
+ const summaries = await getConversationSummaries(application, {
30
+ limit,
31
+ skip,
32
+ });
33
+
34
+ response.render("conversations", {
35
+ title: "Conversations",
36
+ summaries,
37
+ page,
38
+ baseUrl: application.conversations?.mountPath || "/conversations",
39
+ });
40
+ } catch (error) {
41
+ console.error("[Conversations] List error:", error.message);
42
+ response.status(500).render("conversations", {
43
+ title: "Conversations",
44
+ summaries: [],
45
+ error: error.message,
46
+ });
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Conversation detail for a post (admin UI)
52
+ * GET /conversations/post?url=...
53
+ */
54
+ async function detail(request, response) {
55
+ const { application } = request.app.locals;
56
+ const { url } = request.query;
57
+
58
+ if (!url) {
59
+ return response.status(400).render("conversation-detail", {
60
+ title: "Conversation",
61
+ items: [],
62
+ error: "URL parameter required",
63
+ });
64
+ }
65
+
66
+ try {
67
+ const items = await getConversationItems(application, url);
68
+
69
+ // Group by source
70
+ const grouped = {
71
+ webmention: items.filter((i) => i.source === "webmention"),
72
+ mastodon: items.filter((i) => i.source === "mastodon"),
73
+ bluesky: items.filter((i) => i.source === "bluesky"),
74
+ };
75
+
76
+ response.render("conversation-detail", {
77
+ title: "Conversation",
78
+ canonicalUrl: url,
79
+ items,
80
+ grouped,
81
+ baseUrl: application.conversations?.mountPath || "/conversations",
82
+ });
83
+ } catch (error) {
84
+ console.error("[Conversations] Detail error:", error.message);
85
+ response.status(500).render("conversation-detail", {
86
+ title: "Conversation",
87
+ items: [],
88
+ error: error.message,
89
+ });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * JSON API — get conversation items for a canonical URL
95
+ * GET /conversations/api/post?url=...
96
+ */
97
+ async function apiPost(request, response) {
98
+ const { application } = request.app.locals;
99
+ const { url, source } = request.query;
100
+
101
+ if (!url) {
102
+ return response.status(400).json({ error: "url parameter required" });
103
+ }
104
+
105
+ try {
106
+ const options = {};
107
+ if (source) options.source = source;
108
+
109
+ const items = await getConversationItems(application, url, options);
110
+
111
+ // Group by source for easy consumption
112
+ const grouped = {};
113
+ for (const item of items) {
114
+ if (!grouped[item.source]) grouped[item.source] = [];
115
+ grouped[item.source].push(item);
116
+ }
117
+
118
+ response.json({
119
+ canonical_url: url,
120
+ total: items.length,
121
+ items,
122
+ grouped,
123
+ });
124
+ } catch (error) {
125
+ console.error("[Conversations] API error:", error.message);
126
+ response.status(500).json({ error: error.message });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Ingest a webmention
132
+ * POST /conversations/ingest
133
+ * Accepts webmention data, classifies it, and stores it
134
+ */
135
+ async function ingest(request, response) {
136
+ const { application } = request.app.locals;
137
+ const siteUrl = application.url || process.env.SITE_URL;
138
+
139
+ try {
140
+ const webmention = request.body;
141
+
142
+ if (!webmention.source || !webmention.target) {
143
+ return response.status(400).json({
144
+ error: "source and target are required",
145
+ });
146
+ }
147
+
148
+ // Classify the webmention
149
+ const classification = classifyWebmention(webmention);
150
+
151
+ // Resolve canonical URL (target may be a syndication URL)
152
+ const canonicalUrl = await resolveCanonicalUrl(
153
+ application,
154
+ webmention.target,
155
+ siteUrl,
156
+ );
157
+
158
+ // Build conversation item
159
+ const item = {
160
+ canonical_url: canonicalUrl,
161
+ source: classification.source,
162
+ type: classification.type,
163
+ author: webmention.author || {
164
+ name: "Unknown",
165
+ url: webmention.source,
166
+ },
167
+ content: webmention.content?.text || webmention.content?.html || null,
168
+ url: webmention.source,
169
+ bridgy_url: classification.bridgy_url,
170
+ platform_id: generatePlatformId(webmention),
171
+ };
172
+
173
+ await upsertConversationItem(application, item);
174
+
175
+ response.status(202).json({ status: "accepted", classification });
176
+ } catch (error) {
177
+ console.error("[Conversations] Ingest error:", error.message);
178
+ response.status(500).json({ error: error.message });
179
+ }
180
+ }
181
+
182
+ export const conversationsController = { list, detail, apiPost, ingest };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Granary REST API client
3
+ * Optional format conversion between ActivityStreams/microformats2/AT Protocol
4
+ * Uses the Granary REST API: https://granary.io/
5
+ * @module ingestion/granary-client
6
+ */
7
+
8
+ /**
9
+ * Convert content between formats using Granary REST API
10
+ * @param {string} url - URL of the content to convert
11
+ * @param {object} options - Conversion options
12
+ * @param {string} options.input - Input format (activitystreams, html, atom, jsonfeed)
13
+ * @param {string} options.output - Output format (html, activitystreams, atom, jsonfeed, mf2-json)
14
+ * @param {string} [options.granaryUrl] - Custom Granary instance URL
15
+ * @returns {Promise<string>} Converted content
16
+ */
17
+ export async function convert(url, options) {
18
+ const {
19
+ input = "html",
20
+ output = "mf2-json",
21
+ granaryUrl = "https://granary.io",
22
+ } = options;
23
+
24
+ const apiUrl = new URL("/url", granaryUrl);
25
+ apiUrl.searchParams.set("input", input);
26
+ apiUrl.searchParams.set("output", output);
27
+ apiUrl.searchParams.set("url", url);
28
+
29
+ const response = await fetch(apiUrl.toString(), {
30
+ headers: {
31
+ "User-Agent": "IndieKit-Conversations/1.0",
32
+ },
33
+ });
34
+
35
+ if (!response.ok) {
36
+ throw new Error(
37
+ `Granary API ${response.status}: ${response.statusText}`,
38
+ );
39
+ }
40
+
41
+ if (output === "mf2-json" || output === "activitystreams") {
42
+ return response.json();
43
+ }
44
+
45
+ return response.text();
46
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Webmention classifier
3
+ * Classifies incoming webmentions by source protocol
4
+ * @module ingestion/webmention-classifier
5
+ */
6
+
7
+ /**
8
+ * Classify a webmention source URL by protocol origin
9
+ * @param {object} webmention - Webmention data
10
+ * @param {string} webmention.source - Source URL of the webmention
11
+ * @param {object} [webmention.author] - Author data if available
12
+ * @returns {object} Classification result
13
+ */
14
+ export function classifyWebmention(webmention) {
15
+ const source = webmention.source || "";
16
+ const authorUrl = webmention.author?.url || "";
17
+
18
+ // Bridgy pattern: https://brid.gy/{action}/{platform}/...
19
+ const bridgyMatch = source.match(
20
+ /brid\.gy\/(comment|like|repost|mention)\/(mastodon|bluesky|twitter|flickr|github)\//i,
21
+ );
22
+
23
+ if (bridgyMatch) {
24
+ const [, action, platform] = bridgyMatch;
25
+ return {
26
+ source: mapBridgyPlatform(platform),
27
+ type: mapBridgyAction(action),
28
+ bridgy_url: source,
29
+ confidence: "high",
30
+ };
31
+ }
32
+
33
+ // Bridgy Fed pattern: https://fed.brid.gy/...
34
+ if (source.includes("fed.brid.gy")) {
35
+ return {
36
+ source: "mastodon",
37
+ type: inferTypeFromUrl(source),
38
+ bridgy_url: source,
39
+ confidence: "high",
40
+ };
41
+ }
42
+
43
+ // Direct URL pattern matching (non-Bridgy webmentions)
44
+ if (authorUrl.includes("bsky.app") || source.includes("bsky.app")) {
45
+ return {
46
+ source: "bluesky",
47
+ type: inferTypeFromUrl(source),
48
+ bridgy_url: null,
49
+ confidence: "medium",
50
+ };
51
+ }
52
+
53
+ if (isFediverseUrl(authorUrl) || isFediverseUrl(source)) {
54
+ return {
55
+ source: "mastodon",
56
+ type: inferTypeFromUrl(source),
57
+ bridgy_url: null,
58
+ confidence: "medium",
59
+ };
60
+ }
61
+
62
+ // Default: direct webmention from the open web
63
+ return {
64
+ source: "webmention",
65
+ type: webmention["wm-property"]
66
+ ? mapWmProperty(webmention["wm-property"])
67
+ : inferTypeFromUrl(source),
68
+ bridgy_url: null,
69
+ confidence: "low",
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Map Bridgy platform names to our source types
75
+ */
76
+ function mapBridgyPlatform(platform) {
77
+ const map = {
78
+ mastodon: "mastodon",
79
+ bluesky: "bluesky",
80
+ twitter: "twitter",
81
+ flickr: "flickr",
82
+ github: "github",
83
+ };
84
+ return map[platform.toLowerCase()] || "webmention";
85
+ }
86
+
87
+ /**
88
+ * Map Bridgy action types to interaction types
89
+ */
90
+ function mapBridgyAction(action) {
91
+ const map = {
92
+ comment: "reply",
93
+ like: "like",
94
+ repost: "repost",
95
+ mention: "mention",
96
+ };
97
+ return map[action.toLowerCase()] || "mention";
98
+ }
99
+
100
+ /**
101
+ * Map webmention.io wm-property to interaction type
102
+ */
103
+ function mapWmProperty(property) {
104
+ const map = {
105
+ "in-reply-to": "reply",
106
+ "like-of": "like",
107
+ "repost-of": "repost",
108
+ "bookmark-of": "bookmark",
109
+ "mention-of": "mention",
110
+ };
111
+ return map[property] || "mention";
112
+ }
113
+
114
+ /**
115
+ * Infer interaction type from URL patterns
116
+ */
117
+ function inferTypeFromUrl(url) {
118
+ if (!url) return "mention";
119
+ if (url.includes("/reply") || url.includes("/comment")) return "reply";
120
+ if (url.includes("/like") || url.includes("/favourite")) return "like";
121
+ if (url.includes("/repost") || url.includes("/reblog")) return "repost";
122
+ return "mention";
123
+ }
124
+
125
+ /**
126
+ * Check if URL belongs to a known Fediverse instance
127
+ */
128
+ function isFediverseUrl(url) {
129
+ if (!url) return false;
130
+ const lower = url.toLowerCase();
131
+ return (
132
+ lower.includes("mastodon.") ||
133
+ lower.includes("mstdn.") ||
134
+ lower.includes("fosstodon.") ||
135
+ lower.includes("pleroma.") ||
136
+ lower.includes("misskey.") ||
137
+ lower.includes("pixelfed.")
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Generate a platform-specific dedup key
143
+ * @param {object} webmention - Classified webmention data
144
+ * @returns {string} Dedup key like "mastodon:123456" or "webmention:https://..."
145
+ */
146
+ export function generatePlatformId(webmention) {
147
+ const source = webmention.source || "";
148
+
149
+ // Extract Mastodon status ID from URL
150
+ const mastodonMatch = source.match(
151
+ /\/@[^/]+\/(\d+)/,
152
+ );
153
+ if (mastodonMatch) {
154
+ return `mastodon:${mastodonMatch[1]}`;
155
+ }
156
+
157
+ // Extract Bluesky rkey from URL
158
+ const bskyMatch = source.match(
159
+ /bsky\.app\/profile\/[^/]+\/post\/([a-z0-9]+)/i,
160
+ );
161
+ if (bskyMatch) {
162
+ return `bluesky:${bskyMatch[1]}`;
163
+ }
164
+
165
+ // Fallback: hash the source URL
166
+ return `webmention:${source}`;
167
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Syndication URL reverse lookup
3
+ * Maps syndication URLs back to canonical post URLs
4
+ * @module matching/syndication-map
5
+ */
6
+
7
+ /**
8
+ * Find the canonical post URL for a syndication URL
9
+ * Queries the posts collection for posts with matching syndication entries
10
+ * @param {object} application - Indiekit application
11
+ * @param {string} syndicationUrl - The syndication URL to look up
12
+ * @returns {Promise<string|null>} Canonical post URL or null
13
+ */
14
+ export async function findCanonicalPost(application, syndicationUrl) {
15
+ const posts = application.collections.get("posts");
16
+ if (!posts) return null;
17
+
18
+ const post = await posts.findOne({
19
+ "properties.syndication": syndicationUrl,
20
+ });
21
+
22
+ return post?.properties?.url || null;
23
+ }
24
+
25
+ /**
26
+ * Find the canonical post URL by matching against multiple possible target URLs
27
+ * Used when a webmention target could be either the canonical URL or a syndication URL
28
+ * @param {object} application - Indiekit application
29
+ * @param {string} targetUrl - The webmention target URL
30
+ * @param {string} siteUrl - The site's base URL
31
+ * @returns {Promise<string>} Canonical post URL (may be the target itself)
32
+ */
33
+ export async function resolveCanonicalUrl(application, targetUrl, siteUrl) {
34
+ // If the target is already on our domain, it's likely canonical
35
+ if (targetUrl.startsWith(siteUrl)) {
36
+ return targetUrl;
37
+ }
38
+
39
+ // Otherwise try to find via syndication reverse lookup
40
+ const canonical = await findCanonicalPost(application, targetUrl);
41
+ return canonical || targetUrl;
42
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Bluesky notification fetcher
3
+ * Optional direct polling of Bluesky notifications
4
+ * @module notifications/bluesky
5
+ */
6
+
7
+ /**
8
+ * Fetch recent Bluesky notifications
9
+ * @param {object} options - Bluesky connection options
10
+ * @param {string} options.identifier - Bluesky handle or DID
11
+ * @param {string} options.password - App password
12
+ * @param {string} options.serviceUrl - PDS service URL
13
+ * @param {string} [options.cursor] - Pagination cursor from previous fetch
14
+ * @returns {Promise<object>} { items: Array, cursor: string }
15
+ */
16
+ export async function fetchBlueskyNotifications(options) {
17
+ const { identifier, password, serviceUrl = "https://bsky.social" } = options;
18
+
19
+ if (!identifier || !password) {
20
+ throw new Error("Bluesky identifier and password required");
21
+ }
22
+
23
+ // Create session
24
+ const sessionResponse = await fetch(
25
+ `${serviceUrl}/xrpc/com.atproto.server.createSession`,
26
+ {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({ identifier, password }),
30
+ },
31
+ );
32
+
33
+ if (!sessionResponse.ok) {
34
+ throw new Error(
35
+ `Bluesky auth failed: ${sessionResponse.status}`,
36
+ );
37
+ }
38
+
39
+ const session = await sessionResponse.json();
40
+
41
+ // Fetch notifications
42
+ const params = new URLSearchParams({ limit: "50" });
43
+ if (options.cursor) params.set("cursor", options.cursor);
44
+
45
+ const notifResponse = await fetch(
46
+ `${serviceUrl}/xrpc/app.bsky.notification.listNotifications?${params.toString()}`,
47
+ {
48
+ headers: { Authorization: `Bearer ${session.accessJwt}` },
49
+ },
50
+ );
51
+
52
+ if (!notifResponse.ok) {
53
+ throw new Error(
54
+ `Bluesky notifications failed: ${notifResponse.status}`,
55
+ );
56
+ }
57
+
58
+ const data = await notifResponse.json();
59
+ const relevantReasons = new Set(["reply", "like", "repost", "mention"]);
60
+
61
+ const items = data.notifications
62
+ .filter((n) => relevantReasons.has(n.reason))
63
+ .map((notification) => ({
64
+ platform: "bluesky",
65
+ platform_id: `bluesky:${notification.uri}`,
66
+ type: mapNotificationReason(notification.reason),
67
+ author: {
68
+ name:
69
+ notification.author.displayName || notification.author.handle,
70
+ url: `https://bsky.app/profile/${notification.author.handle}`,
71
+ photo: notification.author.avatar,
72
+ },
73
+ content: notification.record?.text || null,
74
+ url: uriToUrl(notification.uri, notification.author.handle),
75
+ created_at: notification.indexedAt,
76
+ raw_uri: notification.uri,
77
+ }));
78
+
79
+ return {
80
+ items,
81
+ cursor: data.cursor,
82
+ };
83
+ }
84
+
85
+ function mapNotificationReason(reason) {
86
+ const map = {
87
+ reply: "reply",
88
+ like: "like",
89
+ repost: "repost",
90
+ mention: "mention",
91
+ };
92
+ return map[reason] || "mention";
93
+ }
94
+
95
+ /**
96
+ * Convert AT URI to Bluesky web URL
97
+ */
98
+ function uriToUrl(uri, handle) {
99
+ if (!uri) return null;
100
+ const match = uri.match(/at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)/);
101
+ if (match) {
102
+ return `https://bsky.app/profile/${handle}/post/${match[2]}`;
103
+ }
104
+ return null;
105
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Mastodon notification fetcher
3
+ * Optional direct polling of Mastodon notifications
4
+ * @module notifications/mastodon
5
+ */
6
+
7
+ /**
8
+ * Fetch recent Mastodon notifications
9
+ * @param {object} options - Mastodon connection options
10
+ * @param {string} options.url - Mastodon instance URL
11
+ * @param {string} options.accessToken - Access token
12
+ * @param {string} [options.sinceId] - Only fetch notifications newer than this ID
13
+ * @returns {Promise<Array>} Normalized notification items
14
+ */
15
+ export async function fetchMastodonNotifications(options) {
16
+ const { url, accessToken, sinceId } = options;
17
+
18
+ if (!url || !accessToken) {
19
+ throw new Error("Mastodon URL and access token required");
20
+ }
21
+
22
+ const params = new URLSearchParams({
23
+ limit: "40",
24
+ types: ["mention", "favourite", "reblog"].join(","),
25
+ });
26
+ if (sinceId) params.set("since_id", sinceId);
27
+
28
+ const response = await fetch(
29
+ `${url}/api/v1/notifications?${params.toString()}`,
30
+ {
31
+ headers: {
32
+ Authorization: `Bearer ${accessToken}`,
33
+ },
34
+ },
35
+ );
36
+
37
+ if (!response.ok) {
38
+ throw new Error(`Mastodon API ${response.status}: ${response.statusText}`);
39
+ }
40
+
41
+ const notifications = await response.json();
42
+
43
+ return notifications.map((notification) => ({
44
+ platform: "mastodon",
45
+ platform_id: `mastodon:${notification.id}`,
46
+ type: mapNotificationType(notification.type),
47
+ author: {
48
+ name: notification.account.display_name || notification.account.username,
49
+ url: notification.account.url,
50
+ photo: notification.account.avatar,
51
+ },
52
+ content: notification.status?.content || null,
53
+ url: notification.status?.url || notification.account.url,
54
+ status_url: notification.status?.url,
55
+ created_at: notification.created_at,
56
+ raw_id: notification.id,
57
+ }));
58
+ }
59
+
60
+ function mapNotificationType(type) {
61
+ const map = {
62
+ mention: "reply",
63
+ favourite: "like",
64
+ reblog: "repost",
65
+ };
66
+ return map[type] || "mention";
67
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Background polling scheduler
3
+ * Only active when direct polling is enabled via config
4
+ * @module polling/scheduler
5
+ */
6
+
7
+ import { findCanonicalPost } from "../matching/syndication-map.js";
8
+ import { upsertConversationItem } from "../storage/conversation-items.js";
9
+
10
+ const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
11
+ let pollTimer = null;
12
+
13
+ /**
14
+ * Start the background polling loop
15
+ * @param {object} application - Indiekit application
16
+ * @param {object} options - Plugin options
17
+ */
18
+ export function startPolling(application, options) {
19
+ console.info("[Conversations] Starting direct polling scheduler");
20
+
21
+ // Run immediately, then on interval
22
+ runPollCycle(application, options).catch((error) => {
23
+ console.error("[Conversations] Initial poll cycle error:", error.message);
24
+ });
25
+
26
+ pollTimer = setInterval(() => {
27
+ runPollCycle(application, options).catch((error) => {
28
+ console.error("[Conversations] Poll cycle error:", error.message);
29
+ });
30
+ }, POLL_INTERVAL);
31
+ }
32
+
33
+ /**
34
+ * Stop the polling scheduler
35
+ */
36
+ export function stopPolling() {
37
+ if (pollTimer) {
38
+ clearInterval(pollTimer);
39
+ pollTimer = null;
40
+ console.info("[Conversations] Polling scheduler stopped");
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Run a single poll cycle
46
+ * @param {object} application - Indiekit application
47
+ * @param {object} options - Plugin options
48
+ */
49
+ async function runPollCycle(application, options) {
50
+ const stateCollection = application.collections.get("conversation_state");
51
+ const state =
52
+ (await stateCollection.findOne({ _id: "poll_cursors" })) || {};
53
+
54
+ // Poll Mastodon
55
+ if (options.directPolling?.mastodon) {
56
+ try {
57
+ const { fetchMastodonNotifications } = await import(
58
+ "../notifications/mastodon.js"
59
+ );
60
+
61
+ const mastodonUrl =
62
+ process.env.MASTODON_URL || process.env.MASTODON_INSTANCE;
63
+ const mastodonToken = process.env.MASTODON_ACCESS_TOKEN;
64
+
65
+ if (mastodonUrl && mastodonToken) {
66
+ const notifications = await fetchMastodonNotifications({
67
+ url: mastodonUrl,
68
+ accessToken: mastodonToken,
69
+ sinceId: state.mastodon_since_id,
70
+ });
71
+
72
+ let stored = 0;
73
+ for (const notification of notifications) {
74
+ const canonicalUrl = notification.status_url
75
+ ? await findCanonicalPost(application, notification.status_url)
76
+ : null;
77
+
78
+ if (canonicalUrl) {
79
+ await upsertConversationItem(application, {
80
+ canonical_url: canonicalUrl,
81
+ source: "mastodon",
82
+ type: notification.type,
83
+ author: notification.author,
84
+ content: notification.content,
85
+ url: notification.url,
86
+ bridgy_url: null,
87
+ platform_id: notification.platform_id,
88
+ });
89
+ stored++;
90
+ }
91
+ }
92
+
93
+ // Update cursor
94
+ if (notifications.length > 0) {
95
+ const latestId = notifications[0].raw_id;
96
+ await stateCollection.findOneAndUpdate(
97
+ { _id: "poll_cursors" },
98
+ { $set: { mastodon_since_id: latestId } },
99
+ { upsert: true },
100
+ );
101
+ }
102
+
103
+ if (stored > 0) {
104
+ console.info(
105
+ `[Conversations] Mastodon: stored ${stored} new interactions`,
106
+ );
107
+ }
108
+ }
109
+ } catch (error) {
110
+ console.error(
111
+ "[Conversations] Mastodon poll error:",
112
+ error.message,
113
+ );
114
+ }
115
+ }
116
+
117
+ // Poll Bluesky
118
+ if (options.directPolling?.bluesky) {
119
+ try {
120
+ const { fetchBlueskyNotifications } = await import(
121
+ "../notifications/bluesky.js"
122
+ );
123
+
124
+ const bskyIdentifier =
125
+ process.env.BLUESKY_IDENTIFIER || process.env.BLUESKY_HANDLE;
126
+ const bskyPassword = process.env.BLUESKY_PASSWORD;
127
+
128
+ if (bskyIdentifier && bskyPassword) {
129
+ const result = await fetchBlueskyNotifications({
130
+ identifier: bskyIdentifier,
131
+ password: bskyPassword,
132
+ cursor: state.bluesky_cursor,
133
+ });
134
+
135
+ let stored = 0;
136
+ for (const notification of result.items) {
137
+ const canonicalUrl = notification.url
138
+ ? await findCanonicalPost(application, notification.url)
139
+ : null;
140
+
141
+ if (canonicalUrl) {
142
+ await upsertConversationItem(application, {
143
+ canonical_url: canonicalUrl,
144
+ source: "bluesky",
145
+ type: notification.type,
146
+ author: notification.author,
147
+ content: notification.content,
148
+ url: notification.url,
149
+ bridgy_url: null,
150
+ platform_id: notification.platform_id,
151
+ });
152
+ stored++;
153
+ }
154
+ }
155
+
156
+ // Update cursor
157
+ if (result.cursor) {
158
+ await stateCollection.findOneAndUpdate(
159
+ { _id: "poll_cursors" },
160
+ { $set: { bluesky_cursor: result.cursor } },
161
+ { upsert: true },
162
+ );
163
+ }
164
+
165
+ if (stored > 0) {
166
+ console.info(
167
+ `[Conversations] Bluesky: stored ${stored} new interactions`,
168
+ );
169
+ }
170
+ }
171
+ } catch (error) {
172
+ console.error(
173
+ "[Conversations] Bluesky poll error:",
174
+ error.message,
175
+ );
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Conversation items storage
3
+ * MongoDB CRUD for conversation items with deduplication
4
+ * @module storage/conversation-items
5
+ */
6
+
7
+ /**
8
+ * Get the conversation_items collection
9
+ * @param {object} application - Indiekit application
10
+ * @returns {object} MongoDB collection
11
+ */
12
+ function getCollection(application) {
13
+ return application.collections.get("conversation_items");
14
+ }
15
+
16
+ /**
17
+ * Upsert a conversation item (insert or update, dedup by platform_id)
18
+ * @param {object} application - Indiekit application
19
+ * @param {object} item - Conversation item data
20
+ * @returns {Promise<object>} Upserted item
21
+ */
22
+ export async function upsertConversationItem(application, item) {
23
+ const collection = getCollection(application);
24
+
25
+ const result = await collection.findOneAndUpdate(
26
+ {
27
+ canonical_url: item.canonical_url,
28
+ platform_id: item.platform_id,
29
+ },
30
+ {
31
+ $set: {
32
+ ...item,
33
+ updated_at: new Date().toISOString(),
34
+ },
35
+ $setOnInsert: {
36
+ received_at: new Date().toISOString(),
37
+ },
38
+ },
39
+ {
40
+ upsert: true,
41
+ returnDocument: "after",
42
+ },
43
+ );
44
+
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Get conversation items for a canonical URL
50
+ * @param {object} application - Indiekit application
51
+ * @param {string} canonicalUrl - The canonical post URL
52
+ * @param {object} [options] - Query options
53
+ * @param {string} [options.source] - Filter by source protocol
54
+ * @param {string} [options.type] - Filter by interaction type
55
+ * @param {number} [options.limit] - Max items to return
56
+ * @returns {Promise<Array>} Array of conversation items
57
+ */
58
+ export async function getConversationItems(
59
+ application,
60
+ canonicalUrl,
61
+ options = {},
62
+ ) {
63
+ const collection = getCollection(application);
64
+ const query = { canonical_url: canonicalUrl };
65
+
66
+ if (options.source) query.source = options.source;
67
+ if (options.type) query.type = options.type;
68
+
69
+ return collection
70
+ .find(query)
71
+ .sort({ received_at: -1 })
72
+ .limit(options.limit || 100)
73
+ .toArray();
74
+ }
75
+
76
+ /**
77
+ * Get conversation summaries (posts with interaction counts)
78
+ * @param {object} application - Indiekit application
79
+ * @param {object} [options] - Query options
80
+ * @param {number} [options.limit] - Max posts to return
81
+ * @param {number} [options.skip] - Number of posts to skip
82
+ * @returns {Promise<Array>} Array of post summaries with counts
83
+ */
84
+ export async function getConversationSummaries(application, options = {}) {
85
+ const collection = getCollection(application);
86
+
87
+ return collection
88
+ .aggregate([
89
+ {
90
+ $group: {
91
+ _id: "$canonical_url",
92
+ total: { $sum: 1 },
93
+ replies: {
94
+ $sum: { $cond: [{ $eq: ["$type", "reply"] }, 1, 0] },
95
+ },
96
+ likes: {
97
+ $sum: { $cond: [{ $eq: ["$type", "like"] }, 1, 0] },
98
+ },
99
+ reposts: {
100
+ $sum: { $cond: [{ $eq: ["$type", "repost"] }, 1, 0] },
101
+ },
102
+ mentions: {
103
+ $sum: { $cond: [{ $eq: ["$type", "mention"] }, 1, 0] },
104
+ },
105
+ sources: { $addToSet: "$source" },
106
+ last_activity: { $max: "$received_at" },
107
+ },
108
+ },
109
+ { $sort: { last_activity: -1 } },
110
+ { $skip: options.skip || 0 },
111
+ { $limit: options.limit || 50 },
112
+ ])
113
+ .toArray();
114
+ }
115
+
116
+ /**
117
+ * Get total count of conversation items
118
+ * @param {object} application - Indiekit application
119
+ * @returns {Promise<number>} Total count
120
+ */
121
+ export async function getConversationCount(application) {
122
+ const collection = getCollection(application);
123
+ return collection.countDocuments();
124
+ }
125
+
126
+ /**
127
+ * Delete conversation items for a canonical URL
128
+ * @param {object} application - Indiekit application
129
+ * @param {string} canonicalUrl - The canonical post URL
130
+ * @returns {Promise<number>} Number of deleted items
131
+ */
132
+ export async function deleteConversationItems(application, canonicalUrl) {
133
+ const collection = getCollection(application);
134
+ const result = await collection.deleteMany({ canonical_url: canonicalUrl });
135
+ return result.deletedCount;
136
+ }
137
+
138
+ /**
139
+ * Create MongoDB indexes for conversation_items
140
+ * @param {object} application - Indiekit application
141
+ */
142
+ export async function createIndexes(application) {
143
+ const collection = getCollection(application);
144
+
145
+ await collection.createIndex(
146
+ { canonical_url: 1, platform_id: 1 },
147
+ { unique: true, name: "dedup_index" },
148
+ );
149
+
150
+ await collection.createIndex(
151
+ { canonical_url: 1, received_at: -1 },
152
+ { name: "conversation_thread" },
153
+ );
154
+
155
+ await collection.createIndex(
156
+ { source: 1, received_at: -1 },
157
+ { name: "source_filter" },
158
+ );
159
+
160
+ console.info("[Conversations] MongoDB indexes created");
161
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "conversations": {
3
+ "title": "Conversations",
4
+ "list": {
5
+ "title": "Conversations",
6
+ "empty": "No conversations yet. Interactions from Mastodon, Bluesky, and the web will appear here.",
7
+ "total": "{{count}} interactions",
8
+ "lastActivity": "Last activity"
9
+ },
10
+ "detail": {
11
+ "title": "Conversation",
12
+ "empty": "No interactions for this post yet.",
13
+ "replies": "Replies",
14
+ "likes": "Likes",
15
+ "reposts": "Reposts",
16
+ "mentions": "Mentions"
17
+ },
18
+ "source": {
19
+ "webmention": "Webmention",
20
+ "mastodon": "Mastodon",
21
+ "bluesky": "Bluesky"
22
+ }
23
+ }
24
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-conversations",
3
+ "version": "1.0.0",
4
+ "description": "Conversation aggregation endpoint for Indiekit. Unified cross-protocol conversation views with Bridgy-primary webmention ingestion and optional direct API polling.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "conversations",
10
+ "webmention",
11
+ "textcasting"
12
+ ],
13
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-conversations",
14
+ "author": {
15
+ "name": "Ricardo Mendes",
16
+ "url": "https://rmendes.net"
17
+ },
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "type": "module",
23
+ "main": "index.js",
24
+ "files": [
25
+ "lib",
26
+ "locales",
27
+ "views",
28
+ "index.js"
29
+ ],
30
+ "bugs": {
31
+ "url": "https://github.com/rmdes/indiekit-endpoint-conversations/issues"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/rmdes/indiekit-endpoint-conversations.git"
36
+ },
37
+ "dependencies": {
38
+ "@indiekit/error": "^1.0.0-beta.25",
39
+ "@indiekit/frontend": "^1.0.0-beta.25",
40
+ "express": "^5.0.0"
41
+ },
42
+ "optionalDependencies": {
43
+ "masto": "^7.0.0",
44
+ "@atproto/api": "^0.13.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
@@ -0,0 +1,116 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="panel">
5
+ <nav class="breadcrumbs" style="margin-bottom: 1rem">
6
+ <a href="{{ baseUrl }}">&larr; {{ __("conversations.list.title") }}</a>
7
+ </nav>
8
+
9
+ <h1>{{ __("conversations.detail.title") }}</h1>
10
+
11
+ {% if canonicalUrl %}
12
+ <p style="margin-bottom: 1.5rem">
13
+ <a href="{{ canonicalUrl }}" target="_blank" rel="noopener">
14
+ {{ canonicalUrl | replace("https://", "") | truncate(80) }}
15
+ </a>
16
+ </p>
17
+ {% endif %}
18
+
19
+ {% if error %}
20
+ <p class="badge badge--error">{{ error }}</p>
21
+ {% endif %}
22
+
23
+ {% if items.length > 0 %}
24
+ {# Summary counts #}
25
+ <div style="display: flex; gap: 1.5rem; margin-bottom: 2rem; flex-wrap: wrap">
26
+ {% if grouped.webmention.length > 0 %}
27
+ <span class="badge">
28
+ <svg style="width:12px;height:12px;vertical-align:middle;margin-right:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
29
+ {{ grouped.webmention.length }} web
30
+ </span>
31
+ {% endif %}
32
+ {% if grouped.mastodon.length > 0 %}
33
+ <span class="badge" style="color: #6364ff">
34
+ <svg style="width:12px;height:12px;vertical-align:middle;margin-right:4px" viewBox="0 0 24 24" fill="#6364ff"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
35
+ {{ grouped.mastodon.length }} Mastodon
36
+ </span>
37
+ {% endif %}
38
+ {% if grouped.bluesky.length > 0 %}
39
+ <span class="badge" style="color: #0085ff">
40
+ <svg style="width:12px;height:12px;vertical-align:middle;margin-right:4px" viewBox="0 0 568 501" fill="#0085ff"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
41
+ {{ grouped.bluesky.length }} Bluesky
42
+ </span>
43
+ {% endif %}
44
+ </div>
45
+
46
+ {# Interaction list #}
47
+ <ul class="flow" style="list-style: none; padding: 0">
48
+ {% for item in items %}
49
+ <li style="display: flex; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid var(--color-border, #e5e7eb)">
50
+ {# Author avatar #}
51
+ {% if item.author and item.author.photo %}
52
+ <img src="{{ item.author.photo }}" alt="" style="width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0" loading="lazy" onerror="this.style.display='none'">
53
+ {% else %}
54
+ <div style="width: 36px; height: 36px; border-radius: 50%; background: #e5e7eb; flex-shrink: 0"></div>
55
+ {% endif %}
56
+
57
+ <div style="flex: 1; min-width: 0">
58
+ <div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap">
59
+ {# Author name #}
60
+ {% if item.author %}
61
+ <strong>
62
+ {% if item.author.url %}
63
+ <a href="{{ item.author.url }}" target="_blank" rel="noopener">{{ item.author.name or "Unknown" }}</a>
64
+ {% else %}
65
+ {{ item.author.name or "Unknown" }}
66
+ {% endif %}
67
+ </strong>
68
+ {% endif %}
69
+
70
+ {# Protocol icon #}
71
+ {% if item.source == "bluesky" %}
72
+ <svg style="width:14px;height:14px" viewBox="0 0 568 501" fill="#0085ff" aria-label="Bluesky">
73
+ <path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
74
+ </svg>
75
+ {% elif item.source == "mastodon" %}
76
+ <svg style="width:14px;height:14px" viewBox="0 0 24 24" fill="#6364ff" aria-label="Mastodon">
77
+ <path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
78
+ </svg>
79
+ {% else %}
80
+ <svg style="width:14px;height:14px" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" aria-label="Web">
81
+ <circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
82
+ </svg>
83
+ {% endif %}
84
+
85
+ {# Interaction type #}
86
+ <span class="badge badge--{{ item.type }}">{{ item.type }}</span>
87
+
88
+ {# Timestamp #}
89
+ {% if item.received_at %}
90
+ <time datetime="{{ item.received_at }}" style="font-size: 0.8em; color: #6b7280">
91
+ {{ item.received_at | date("PP") }}
92
+ </time>
93
+ {% endif %}
94
+ </div>
95
+
96
+ {# Content #}
97
+ {% if item.content %}
98
+ <p style="margin-top: 0.25rem">{{ item.content | truncate(300) }}</p>
99
+ {% endif %}
100
+
101
+ {# Link to original #}
102
+ {% if item.url %}
103
+ <a href="{{ item.url }}" target="_blank" rel="noopener" style="font-size: 0.8em">
104
+ View original
105
+ </a>
106
+ {% endif %}
107
+ </div>
108
+ </li>
109
+ {% endfor %}
110
+ </ul>
111
+
112
+ {% else %}
113
+ <p>{{ __("conversations.detail.empty") }}</p>
114
+ {% endif %}
115
+ </div>
116
+ {% endblock %}
@@ -0,0 +1,64 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <div class="panel">
5
+ <h1>{{ __("conversations.list.title") }}</h1>
6
+
7
+ {% if error %}
8
+ <p class="badge badge--error">{{ error }}</p>
9
+ {% endif %}
10
+
11
+ {% if summaries.length > 0 %}
12
+ <table class="table">
13
+ <thead>
14
+ <tr>
15
+ <th>Post</th>
16
+ <th>Replies</th>
17
+ <th>Likes</th>
18
+ <th>Reposts</th>
19
+ <th>Sources</th>
20
+ <th>{{ __("conversations.list.lastActivity") }}</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ {% for summary in summaries %}
25
+ <tr>
26
+ <td>
27
+ <a href="{{ baseUrl }}/post?url={{ summary._id | urlencode }}">
28
+ {{ summary._id | replace("https://", "") | truncate(60) }}
29
+ </a>
30
+ </td>
31
+ <td>{{ summary.replies }}</td>
32
+ <td>{{ summary.likes }}</td>
33
+ <td>{{ summary.reposts }}</td>
34
+ <td>
35
+ {% for src in summary.sources %}
36
+ {% if src == "bluesky" %}
37
+ <svg style="width:14px;height:14px;vertical-align:middle" viewBox="0 0 568 501" fill="#0085ff" aria-label="Bluesky">
38
+ <path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/>
39
+ </svg>
40
+ {% elif src == "mastodon" %}
41
+ <svg style="width:14px;height:14px;vertical-align:middle" viewBox="0 0 24 24" fill="#6364ff" aria-label="Mastodon">
42
+ <path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
43
+ </svg>
44
+ {% elif src == "webmention" %}
45
+ <svg style="width:14px;height:14px;vertical-align:middle" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" aria-label="Web">
46
+ <circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
47
+ </svg>
48
+ {% endif %}
49
+ {% endfor %}
50
+ </td>
51
+ <td>
52
+ {% if summary.last_activity %}
53
+ {{ summary.last_activity | date("PP") }}
54
+ {% endif %}
55
+ </td>
56
+ </tr>
57
+ {% endfor %}
58
+ </tbody>
59
+ </table>
60
+ {% else %}
61
+ <p>{{ __("conversations.list.empty") }}</p>
62
+ {% endif %}
63
+ </div>
64
+ {% endblock %}