@rmdes/indiekit-endpoint-activitypub 1.0.29 → 1.1.2

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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Timeline item extraction helpers
3
+ * @module timeline-store
4
+ */
5
+
6
+ import sanitizeHtml from "sanitize-html";
7
+
8
+ /**
9
+ * Sanitize HTML content for safe display
10
+ * @param {string} html - Raw HTML content
11
+ * @returns {string} Sanitized HTML
12
+ */
13
+ export function sanitizeContent(html) {
14
+ if (!html) return "";
15
+
16
+ return sanitizeHtml(html, {
17
+ allowedTags: [
18
+ "p", "br", "a", "strong", "em", "ul", "ol", "li",
19
+ "blockquote", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
20
+ "span", "div", "img"
21
+ ],
22
+ allowedAttributes: {
23
+ a: ["href", "rel", "class"],
24
+ img: ["src", "alt", "class"],
25
+ span: ["class"],
26
+ div: ["class"]
27
+ },
28
+ allowedSchemes: ["http", "https", "mailto"],
29
+ allowedSchemesByTag: {
30
+ img: ["http", "https", "data"]
31
+ }
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Extract actor information from Fedify Person/Application/Service object
37
+ * @param {object} actor - Fedify actor object
38
+ * @returns {object} { name, url, photo, handle }
39
+ */
40
+ export async function extractActorInfo(actor) {
41
+ if (!actor) {
42
+ return {
43
+ name: "Unknown",
44
+ url: "",
45
+ photo: "",
46
+ handle: ""
47
+ };
48
+ }
49
+
50
+ const rawName = actor.name?.toString() || actor.preferredUsername?.toString() || "Unknown";
51
+ // Strip all HTML from actor names to prevent stored XSS
52
+ const name = sanitizeHtml(rawName, { allowedTags: [], allowedAttributes: {} });
53
+ const url = actor.id?.href || "";
54
+
55
+ // Extract photo URL from icon (Fedify uses async getters)
56
+ let photo = "";
57
+ try {
58
+ if (typeof actor.getIcon === "function") {
59
+ const iconObj = await actor.getIcon();
60
+ photo = iconObj?.url?.href || "";
61
+ } else {
62
+ const iconObj = await actor.icon;
63
+ photo = iconObj?.url?.href || "";
64
+ }
65
+ } catch {
66
+ // No icon available
67
+ }
68
+
69
+ // Extract handle from actor URL
70
+ let handle = "";
71
+ try {
72
+ const actorUrl = new URL(url);
73
+ const username = actor.preferredUsername?.toString() || "";
74
+ if (username) {
75
+ handle = `@${username}@${actorUrl.hostname}`;
76
+ }
77
+ } catch {
78
+ // Invalid URL, keep handle empty
79
+ }
80
+
81
+ return { name, url, photo, handle };
82
+ }
83
+
84
+ /**
85
+ * Extract timeline item data from Fedify Note/Article object
86
+ * @param {object} object - Fedify Note or Article object
87
+ * @param {object} options - Extraction options
88
+ * @param {object} [options.boostedBy] - Actor info for boosts
89
+ * @param {Date} [options.boostedAt] - Boost timestamp
90
+ * @returns {Promise<object>} Timeline item data
91
+ */
92
+ export async function extractObjectData(object, options = {}) {
93
+ if (!object) {
94
+ throw new Error("Object is required");
95
+ }
96
+
97
+ const uid = object.id?.href || "";
98
+ const url = object.url?.href || uid;
99
+
100
+ // Determine type
101
+ let type = "note";
102
+ if (object.type?.toLowerCase() === "article") {
103
+ type = "article";
104
+ }
105
+ if (options.boostedBy) {
106
+ type = "boost";
107
+ }
108
+
109
+ // Extract content
110
+ const contentHtml = object.content?.toString() || "";
111
+ const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
112
+
113
+ const content = {
114
+ text: contentText,
115
+ html: sanitizeContent(contentHtml)
116
+ };
117
+
118
+ // Extract name (articles only)
119
+ const name = type === "article" ? (object.name?.toString() || "") : "";
120
+
121
+ // Content warning / summary
122
+ const summary = object.summary?.toString() || "";
123
+ const sensitive = object.sensitive || false;
124
+
125
+ // Published date — store as ISO string per Indiekit convention
126
+ const published = object.published
127
+ ? String(object.published)
128
+ : new Date().toISOString();
129
+
130
+ // Extract author — use async getAttributedTo() for Fedify objects
131
+ let authorObj = null;
132
+ try {
133
+ if (typeof object.getAttributedTo === "function") {
134
+ const attr = await object.getAttributedTo();
135
+ authorObj = Array.isArray(attr) ? attr[0] : attr;
136
+ }
137
+ } catch {
138
+ // Fallback: try direct property access for plain objects
139
+ authorObj = object.attribution || object.attributedTo || null;
140
+ }
141
+ const author = await extractActorInfo(authorObj);
142
+
143
+ // Extract tags/categories
144
+ const category = [];
145
+ if (object.tag) {
146
+ const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
147
+ for (const tag of tags) {
148
+ if (tag.type === "Hashtag" && tag.name) {
149
+ category.push(tag.name.toString().replace(/^#/, ""));
150
+ }
151
+ }
152
+ }
153
+
154
+ // Extract media attachments
155
+ const photo = [];
156
+ const video = [];
157
+ const audio = [];
158
+
159
+ if (object.attachment) {
160
+ const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
161
+ for (const att of attachments) {
162
+ const mediaUrl = att.url?.href || "";
163
+ if (!mediaUrl) continue;
164
+
165
+ const mediaType = att.mediaType?.toLowerCase() || "";
166
+
167
+ if (mediaType.startsWith("image/")) {
168
+ photo.push(mediaUrl);
169
+ } else if (mediaType.startsWith("video/")) {
170
+ video.push(mediaUrl);
171
+ } else if (mediaType.startsWith("audio/")) {
172
+ audio.push(mediaUrl);
173
+ }
174
+ }
175
+ }
176
+
177
+ // In-reply-to
178
+ const inReplyTo = object.inReplyTo?.href || "";
179
+
180
+ // Build base timeline item
181
+ const item = {
182
+ uid,
183
+ type,
184
+ url,
185
+ name,
186
+ content,
187
+ summary,
188
+ sensitive,
189
+ published,
190
+ author,
191
+ category,
192
+ photo,
193
+ video,
194
+ audio,
195
+ inReplyTo,
196
+ createdAt: new Date().toISOString()
197
+ };
198
+
199
+ // Add boost metadata if this is a boost
200
+ if (options.boostedBy) {
201
+ item.boostedBy = options.boostedBy;
202
+ item.boostedAt = options.boostedAt || new Date().toISOString();
203
+ item.originalUrl = url;
204
+ }
205
+
206
+ return item;
207
+ }
package/locales/en.json CHANGED
@@ -49,7 +49,16 @@
49
49
  "authorizedFetchLabel": "Require authorized fetch (secure mode)",
50
50
  "authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
51
51
  "save": "Save profile",
52
- "saved": "Profile saved. Changes are now visible to the fediverse."
52
+ "saved": "Profile saved. Changes are now visible to the fediverse.",
53
+ "remote": {
54
+ "follow": "Follow",
55
+ "unfollow": "Unfollow",
56
+ "viewOn": "View on",
57
+ "postsTitle": "Posts",
58
+ "noPosts": "No posts from this account yet.",
59
+ "followToSee": "Follow this account to see their posts in your timeline.",
60
+ "notFound": "Could not find this account. It may have been deleted or the server may be unavailable."
61
+ }
53
62
  },
54
63
  "migrate": {
55
64
  "title": "Mastodon migration",
@@ -94,6 +103,80 @@
94
103
  "paused": "Paused",
95
104
  "completed": "Completed"
96
105
  }
106
+ },
107
+ "moderation": {
108
+ "title": "Moderation",
109
+ "blockedTitle": "Blocked accounts",
110
+ "mutedActorsTitle": "Muted accounts",
111
+ "mutedKeywordsTitle": "Muted keywords",
112
+ "noBlocked": "No blocked accounts.",
113
+ "noMutedActors": "No muted accounts.",
114
+ "noMutedKeywords": "No muted keywords.",
115
+ "unblock": "Unblock",
116
+ "unmute": "Unmute",
117
+ "addKeywordTitle": "Add muted keyword",
118
+ "keywordPlaceholder": "Enter keyword or phrase…",
119
+ "addKeyword": "Add",
120
+ "muteActor": "Mute",
121
+ "blockActor": "Block"
122
+ },
123
+ "compose": {
124
+ "title": "Compose reply",
125
+ "modeLabel": "Reply mode",
126
+ "modeMicropub": "Post as blog reply",
127
+ "modeMicropubHint": "Creates a permanent post on your blog, syndicated to the fediverse",
128
+ "modeQuick": "Quick reply",
129
+ "modeQuickHint": "Sends a reply directly to the fediverse (no blog post created)",
130
+ "placeholder": "Write your reply…",
131
+ "syndicateLabel": "Syndicate to",
132
+ "submitMicropub": "Post reply",
133
+ "submitQuick": "Send reply",
134
+ "cancel": "Cancel",
135
+ "errorEmpty": "Reply content cannot be empty"
136
+ },
137
+ "notifications": {
138
+ "title": "Notifications",
139
+ "empty": "No notifications yet. Interactions from other fediverse users will appear here.",
140
+ "liked": "liked your post",
141
+ "boostedPost": "boosted your post",
142
+ "followedYou": "followed you",
143
+ "repliedTo": "replied to your post",
144
+ "mentionedYou": "mentioned you"
145
+ },
146
+ "reader": {
147
+ "title": "Reader",
148
+ "tabs": {
149
+ "all": "All",
150
+ "notes": "Notes",
151
+ "articles": "Articles",
152
+ "replies": "Replies",
153
+ "boosts": "Boosts",
154
+ "media": "Media"
155
+ },
156
+ "pagination": {
157
+ "newer": "← Newer",
158
+ "older": "Older →"
159
+ },
160
+ "empty": "Your timeline is empty. Follow some accounts to see their posts here.",
161
+ "boosted": "boosted",
162
+ "replyingTo": "Replying to",
163
+ "showContent": "Show content",
164
+ "hideContent": "Hide content",
165
+ "sensitiveContent": "Sensitive content",
166
+ "videoNotSupported": "Your browser does not support the video element.",
167
+ "audioNotSupported": "Your browser does not support the audio element.",
168
+ "actions": {
169
+ "reply": "Reply",
170
+ "boost": "Boost",
171
+ "unboost": "Undo boost",
172
+ "like": "Like",
173
+ "unlike": "Unlike",
174
+ "viewOriginal": "View original",
175
+ "liked": "Liked",
176
+ "boosted": "Boosted",
177
+ "likeError": "Could not like this post",
178
+ "boostError": "Could not boost this post"
179
+ }
97
180
  }
98
181
  }
99
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.29",
3
+ "version": "1.1.2",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -42,7 +42,8 @@
42
42
  "@fedify/redis": "^1.10.3",
43
43
  "@js-temporal/polyfill": "^0.5.0",
44
44
  "express": "^5.0.0",
45
- "ioredis": "^5.9.3"
45
+ "ioredis": "^5.9.3",
46
+ "sanitize-html": "^2.13.1"
46
47
  },
47
48
  "peerDependencies": {
48
49
  "@indiekit/error": "^1.0.0-beta.25",
@@ -0,0 +1,94 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+
5
+ {% block content %}
6
+ {{ heading({
7
+ text: title,
8
+ level: 1,
9
+ parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
10
+ }) }}
11
+
12
+ {# Reply context — show the post being replied to #}
13
+ {% if replyContext %}
14
+ <div class="ap-compose__context">
15
+ <div class="ap-compose__context-label">{{ __("activitypub.reader.replyingTo") }}</div>
16
+ {% if replyContext.author %}
17
+ <div class="ap-compose__context-author">
18
+ <a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
19
+ </div>
20
+ {% endif %}
21
+ {% if replyContext.content and replyContext.content.text %}
22
+ <blockquote class="ap-compose__context-text">
23
+ {{ replyContext.content.text | truncate(300) }}
24
+ </blockquote>
25
+ {% endif %}
26
+ <a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
27
+ </div>
28
+ {% endif %}
29
+
30
+ <form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form"
31
+ x-data="{
32
+ mode: 'micropub',
33
+ content: '',
34
+ maxChars: 500,
35
+ get remaining() { return this.maxChars - this.content.length; }
36
+ }">
37
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
38
+ {% if replyTo %}
39
+ <input type="hidden" name="in-reply-to" value="{{ replyTo }}">
40
+ {% endif %}
41
+
42
+ {# Mode toggle #}
43
+ <fieldset class="ap-compose__mode">
44
+ <legend>{{ __("activitypub.compose.modeLabel") }}</legend>
45
+ <label class="ap-compose__mode-option">
46
+ <input type="radio" name="mode" value="micropub" x-model="mode" checked>
47
+ {{ __("activitypub.compose.modeMicropub") }}
48
+ <span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeMicropubHint") }}</span>
49
+ </label>
50
+ <label class="ap-compose__mode-option">
51
+ <input type="radio" name="mode" value="quick" x-model="mode">
52
+ {{ __("activitypub.compose.modeQuick") }}
53
+ <span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeQuickHint") }}</span>
54
+ </label>
55
+ </fieldset>
56
+
57
+ {# Content textarea #}
58
+ <div class="ap-compose__editor">
59
+ <textarea name="content" class="ap-compose__textarea"
60
+ rows="6"
61
+ :maxlength="mode === 'quick' ? maxChars : undefined"
62
+ x-model="content"
63
+ placeholder="{{ __('activitypub.compose.placeholder') }}"
64
+ required></textarea>
65
+ <div class="ap-compose__counter" x-show="mode === 'quick'" x-cloak>
66
+ <span :class="{ 'ap-compose__counter--warn': remaining < 50, 'ap-compose__counter--over': remaining < 0 }"
67
+ x-text="remaining"></span>
68
+ </div>
69
+ </div>
70
+
71
+ {# Syndication targets (Micropub mode only) #}
72
+ {% if syndicationTargets.length > 0 %}
73
+ <fieldset class="ap-compose__syndication" x-show="mode === 'micropub'">
74
+ <legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
75
+ {% for target in syndicationTargets %}
76
+ <label class="ap-compose__syndication-target">
77
+ <input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" checked>
78
+ {{ target.name }}
79
+ </label>
80
+ {% endfor %}
81
+ </fieldset>
82
+ {% endif %}
83
+
84
+ <div class="ap-compose__actions">
85
+ <button type="submit" class="ap-compose__submit">
86
+ <span x-show="mode === 'micropub'">{{ __("activitypub.compose.submitMicropub") }}</span>
87
+ <span x-show="mode === 'quick'">{{ __("activitypub.compose.submitQuick") }}</span>
88
+ </button>
89
+ <a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel">
90
+ {{ __("activitypub.compose.cancel") }}
91
+ </a>
92
+ </div>
93
+ </form>
94
+ {% endblock %}
@@ -0,0 +1,118 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({
8
+ text: title,
9
+ level: 1,
10
+ parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
+ }) }}
12
+
13
+ {# Blocked actors #}
14
+ <section class="ap-moderation__section">
15
+ <h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
16
+ {% if blocked.length > 0 %}
17
+ <ul class="ap-moderation__list">
18
+ {% for entry in blocked %}
19
+ <li class="ap-moderation__entry"
20
+ x-data="{ removing: false }">
21
+ <a href="{{ entry.url }}">{{ entry.url }}</a>
22
+ <button class="ap-moderation__remove"
23
+ :disabled="removing"
24
+ @click="
25
+ removing = true;
26
+ fetch('{{ mountPath }}/admin/reader/unblock', {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
29
+ body: JSON.stringify({ url: '{{ entry.url }}' })
30
+ }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
31
+ ">{{ __("activitypub.moderation.unblock") }}</button>
32
+ </li>
33
+ {% endfor %}
34
+ </ul>
35
+ {% else %}
36
+ {{ prose({ text: __("activitypub.moderation.noBlocked") }) }}
37
+ {% endif %}
38
+ </section>
39
+
40
+ {# Muted actors #}
41
+ <section class="ap-moderation__section">
42
+ <h2>{{ __("activitypub.moderation.mutedActorsTitle") }}</h2>
43
+ {% set mutedActors = muted | selectattr("url") %}
44
+ {% if mutedActors | length > 0 %}
45
+ <ul class="ap-moderation__list">
46
+ {% for entry in mutedActors %}
47
+ <li class="ap-moderation__entry"
48
+ x-data="{ removing: false }">
49
+ <a href="{{ entry.url }}">{{ entry.url }}</a>
50
+ <button class="ap-moderation__remove"
51
+ :disabled="removing"
52
+ @click="
53
+ removing = true;
54
+ fetch('{{ mountPath }}/admin/reader/unmute', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
57
+ body: JSON.stringify({ url: '{{ entry.url }}' })
58
+ }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
59
+ ">{{ __("activitypub.moderation.unmute") }}</button>
60
+ </li>
61
+ {% endfor %}
62
+ </ul>
63
+ {% else %}
64
+ {{ prose({ text: __("activitypub.moderation.noMutedActors") }) }}
65
+ {% endif %}
66
+ </section>
67
+
68
+ {# Muted keywords #}
69
+ <section class="ap-moderation__section">
70
+ <h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
71
+ {% set mutedKeywords = muted | selectattr("keyword") %}
72
+ {% if mutedKeywords | length > 0 %}
73
+ <ul class="ap-moderation__list">
74
+ {% for entry in mutedKeywords %}
75
+ <li class="ap-moderation__entry"
76
+ x-data="{ removing: false }">
77
+ <code>{{ entry.keyword }}</code>
78
+ <button class="ap-moderation__remove"
79
+ :disabled="removing"
80
+ @click="
81
+ removing = true;
82
+ fetch('{{ mountPath }}/admin/reader/unmute', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
85
+ body: JSON.stringify({ keyword: '{{ entry.keyword }}' })
86
+ }).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
87
+ ">{{ __("activitypub.moderation.unmute") }}</button>
88
+ </li>
89
+ {% endfor %}
90
+ </ul>
91
+ {% else %}
92
+ {{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
93
+ {% endif %}
94
+ </section>
95
+
96
+ {# Add keyword mute form #}
97
+ <section class="ap-moderation__section">
98
+ <h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
99
+ <form class="ap-moderation__add-form"
100
+ x-data="{ keyword: '', submitting: false }"
101
+ @submit.prevent="
102
+ if (!keyword.trim()) return;
103
+ submitting = true;
104
+ fetch('{{ mountPath }}/admin/reader/mute', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
107
+ body: JSON.stringify({ keyword: keyword.trim() })
108
+ }).then(r => r.json()).then(d => { if (d.success) location.reload(); submitting = false; }).catch(() => submitting = false);
109
+ ">
110
+ <input type="text" x-model="keyword"
111
+ placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
112
+ class="ap-moderation__input">
113
+ <button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
114
+ {{ __("activitypub.moderation.addKeyword") }}
115
+ </button>
116
+ </form>
117
+ </section>
118
+ {% endblock %}
@@ -0,0 +1,31 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({
8
+ text: __("activitypub.notifications.title"),
9
+ level: 1,
10
+ parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
+ }) }}
12
+
13
+ {% if items.length > 0 %}
14
+ <div class="ap-timeline">
15
+ {% for item in items %}
16
+ {% include "partials/ap-notification-card.njk" %}
17
+ {% endfor %}
18
+ </div>
19
+
20
+ {# Pagination #}
21
+ {% if before %}
22
+ <nav class="ap-pagination">
23
+ <a href="?before={{ before }}" class="ap-pagination__next">
24
+ {{ __("activitypub.reader.pagination.older") }}
25
+ </a>
26
+ </nav>
27
+ {% endif %}
28
+ {% else %}
29
+ {{ prose({ text: __("activitypub.notifications.empty") }) }}
30
+ {% endif %}
31
+ {% endblock %}
@@ -0,0 +1,61 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({
8
+ text: __("activitypub.reader.title"),
9
+ level: 1,
10
+ parent: { text: __("activitypub.title"), href: mountPath }
11
+ }) }}
12
+
13
+ {# Tab navigation #}
14
+ <nav class="ap-tabs" role="tablist">
15
+ <a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
16
+ {{ __("activitypub.reader.tabs.all") }}
17
+ </a>
18
+ <a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
19
+ {{ __("activitypub.reader.tabs.notes") }}
20
+ </a>
21
+ <a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
22
+ {{ __("activitypub.reader.tabs.articles") }}
23
+ </a>
24
+ <a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
25
+ {{ __("activitypub.reader.tabs.replies") }}
26
+ </a>
27
+ <a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
28
+ {{ __("activitypub.reader.tabs.boosts") }}
29
+ </a>
30
+ <a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
31
+ {{ __("activitypub.reader.tabs.media") }}
32
+ </a>
33
+ </nav>
34
+
35
+ {# Timeline items #}
36
+ {% if items.length > 0 %}
37
+ <div class="ap-timeline">
38
+ {% for item in items %}
39
+ {% include "partials/ap-item-card.njk" %}
40
+ {% endfor %}
41
+ </div>
42
+
43
+ {# Pagination #}
44
+ {% if before or after %}
45
+ <nav class="ap-pagination">
46
+ {% if after %}
47
+ <a href="?tab={{ tab }}&after={{ after }}" class="ap-pagination__prev">
48
+ {{ __("activitypub.reader.pagination.newer") }}
49
+ </a>
50
+ {% endif %}
51
+ {% if before %}
52
+ <a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
53
+ {{ __("activitypub.reader.pagination.older") }}
54
+ </a>
55
+ {% endif %}
56
+ </nav>
57
+ {% endif %}
58
+ {% else %}
59
+ {{ prose({ text: __("activitypub.reader.empty") }) }}
60
+ {% endif %}
61
+ {% endblock %}