@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 +117 -0
- package/lib/controllers/conversations.js +182 -0
- package/lib/ingestion/granary-client.js +46 -0
- package/lib/ingestion/webmention-classifier.js +167 -0
- package/lib/matching/syndication-map.js +42 -0
- package/lib/notifications/bluesky.js +105 -0
- package/lib/notifications/mastodon.js +67 -0
- package/lib/polling/scheduler.js +178 -0
- package/lib/storage/conversation-items.js +161 -0
- package/locales/en.json +24 -0
- package/package.json +49 -0
- package/views/conversation-detail.njk +116 -0
- package/views/conversations.njk +64 -0
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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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 }}">← {{ __("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 %}
|