@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/locales/en.json CHANGED
@@ -38,10 +38,27 @@
38
38
  "imageHint": "URL to a banner image shown at the top of your profile",
39
39
  "manualApprovalLabel": "Manually approve followers",
40
40
  "manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
41
+ "actorTypeLabel": "Actor type",
42
+ "actorTypeHint": "How your account appears in the fediverse. Person for individuals, Service for bots or automated accounts, Organization for groups or companies.",
43
+ "linksLabel": "Profile links",
44
+ "linksHint": "Links shown on your fediverse profile. Add your website, social accounts, or other URLs. Pages that link back with rel=\"me\" will show as verified on Mastodon.",
45
+ "linkNameLabel": "Label",
46
+ "linkValueLabel": "URL",
47
+ "addLink": "Add link",
48
+ "removeLink": "Remove",
41
49
  "authorizedFetchLabel": "Require authorized fetch (secure mode)",
42
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.",
43
51
  "save": "Save profile",
44
- "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
+ }
45
62
  },
46
63
  "migrate": {
47
64
  "title": "Mastodon migration",
@@ -86,6 +103,80 @@
86
103
  "paused": "Paused",
87
104
  "completed": "Completed"
88
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
+ }
89
180
  }
90
181
  }
91
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.28",
3
+ "version": "1.1.1",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -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 %}
@@ -4,6 +4,7 @@
4
4
  {% from "input/macro.njk" import input with context %}
5
5
  {% from "textarea/macro.njk" import textarea with context %}
6
6
  {% from "checkboxes/macro.njk" import checkboxes with context %}
7
+ {% from "radios/macro.njk" import radios with context %}
7
8
  {% from "button/macro.njk" import button with context %}
8
9
  {% from "notification-banner/macro.njk" import notificationBanner with context %}
9
10
  {% from "prose/macro.njk" import prose with context %}
@@ -57,6 +58,50 @@
57
58
  type: "url"
58
59
  }) }}
59
60
 
61
+ {{ radios({
62
+ name: "actorType",
63
+ fieldset: {
64
+ legend: __("activitypub.profile.actorTypeLabel")
65
+ },
66
+ hint: __("activitypub.profile.actorTypeHint"),
67
+ items: [{
68
+ label: "Person",
69
+ value: "Person"
70
+ }, {
71
+ label: "Service",
72
+ value: "Service"
73
+ }, {
74
+ label: "Organization",
75
+ value: "Organization"
76
+ }],
77
+ values: [profile.actorType or "Person"]
78
+ }) }}
79
+
80
+ <fieldset class="fieldset" style="margin-block-end: var(--space-l);">
81
+ <legend class="label">{{ __("activitypub.profile.linksLabel") }}</legend>
82
+ <p class="hint">{{ __("activitypub.profile.linksHint") }}</p>
83
+
84
+ <div id="profile-links">
85
+ {% if profile.attachments and profile.attachments.length > 0 %}
86
+ {% for att in profile.attachments %}
87
+ <div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
88
+ <div>
89
+ <label class="label" for="link_name_{{ loop.index }}">{{ __("activitypub.profile.linkNameLabel") }}</label>
90
+ <input class="input" type="text" id="link_name_{{ loop.index }}" name="link_name[]" value="{{ att.name }}" placeholder="Website">
91
+ </div>
92
+ <div>
93
+ <label class="label" for="link_value_{{ loop.index }}">{{ __("activitypub.profile.linkValueLabel") }}</label>
94
+ <input class="input" type="url" id="link_value_{{ loop.index }}" name="link_value[]" value="{{ att.value }}" placeholder="https://example.com">
95
+ </div>
96
+ <button type="button" class="button button--small" onclick="this.closest('.profile-link-row').remove()" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button>
97
+ </div>
98
+ {% endfor %}
99
+ {% endif %}
100
+ </div>
101
+
102
+ <button type="button" class="button button--small" id="add-link-btn">{{ __("activitypub.profile.addLink") }}</button>
103
+ </fieldset>
104
+
60
105
  {{ checkboxes({
61
106
  name: "manuallyApprovesFollowers",
62
107
  items: [
@@ -83,4 +128,57 @@
83
128
 
84
129
  {{ button({ text: __("activitypub.profile.save") }) }}
85
130
  </form>
131
+
132
+ <script>
133
+ (function() {
134
+ var linkCount = {{ (profile.attachments.length if profile.attachments) or 0 }};
135
+ document.getElementById('add-link-btn').addEventListener('click', function() {
136
+ linkCount++;
137
+ var container = document.getElementById('profile-links');
138
+ var row = document.createElement('div');
139
+ row.className = 'profile-link-row';
140
+ row.style.cssText = 'display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);';
141
+
142
+ var nameDiv = document.createElement('div');
143
+ var nameLabel = document.createElement('label');
144
+ nameLabel.className = 'label';
145
+ nameLabel.setAttribute('for', 'link_name_' + linkCount);
146
+ nameLabel.textContent = 'Label';
147
+ var nameInput = document.createElement('input');
148
+ nameInput.className = 'input';
149
+ nameInput.type = 'text';
150
+ nameInput.id = 'link_name_' + linkCount;
151
+ nameInput.name = 'link_name[]';
152
+ nameInput.placeholder = 'Website';
153
+ nameDiv.appendChild(nameLabel);
154
+ nameDiv.appendChild(nameInput);
155
+
156
+ var valueDiv = document.createElement('div');
157
+ var valueLabel = document.createElement('label');
158
+ valueLabel.className = 'label';
159
+ valueLabel.setAttribute('for', 'link_value_' + linkCount);
160
+ valueLabel.textContent = 'URL';
161
+ var valueInput = document.createElement('input');
162
+ valueInput.className = 'input';
163
+ valueInput.type = 'url';
164
+ valueInput.id = 'link_value_' + linkCount;
165
+ valueInput.name = 'link_value[]';
166
+ valueInput.placeholder = 'https://example.com';
167
+ valueDiv.appendChild(valueLabel);
168
+ valueDiv.appendChild(valueInput);
169
+
170
+ var removeBtn = document.createElement('button');
171
+ removeBtn.type = 'button';
172
+ removeBtn.className = 'button button--small';
173
+ removeBtn.style.cssText = 'margin-block-end: 4px;';
174
+ removeBtn.textContent = 'Remove';
175
+ removeBtn.addEventListener('click', function() { row.remove(); });
176
+
177
+ row.appendChild(nameDiv);
178
+ row.appendChild(valueDiv);
179
+ row.appendChild(removeBtn);
180
+ container.appendChild(row);
181
+ });
182
+ })();
183
+ </script>
86
184
  {% 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 %}
@@ -0,0 +1,117 @@
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
+ <div class="ap-profile"
14
+ x-data="{
15
+ following: {{ 'true' if isFollowing else 'false' }},
16
+ muted: {{ 'true' if isMuted else 'false' }},
17
+ blocked: {{ 'true' if isBlocked else 'false' }},
18
+ loading: false,
19
+ async action(endpoint, body) {
20
+ if (this.loading) return;
21
+ this.loading = true;
22
+ try {
23
+ const res = await fetch('{{ mountPath }}/admin/reader/' + endpoint, {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'X-CSRF-Token': '{{ csrfToken }}'
28
+ },
29
+ body: JSON.stringify(body)
30
+ });
31
+ const data = await res.json();
32
+ return data.success;
33
+ } catch { return false; }
34
+ finally { this.loading = false; }
35
+ }
36
+ }">
37
+
38
+ {# Header image #}
39
+ {% if image %}
40
+ <div class="ap-profile__header">
41
+ <img src="{{ image }}" alt="" class="ap-profile__header-img">
42
+ </div>
43
+ {% endif %}
44
+
45
+ {# Profile info #}
46
+ <div class="ap-profile__info">
47
+ <div class="ap-profile__avatar-wrap">
48
+ {% if icon %}
49
+ <img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar">
50
+ {% else %}
51
+ <div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
52
+ {% endif %}
53
+ </div>
54
+
55
+ <div class="ap-profile__details">
56
+ <h2 class="ap-profile__name">{{ name }}</h2>
57
+ {% if actorHandle %}
58
+ <div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
59
+ {% endif %}
60
+ {% if summary %}
61
+ <div class="ap-profile__bio">{{ summary | safe }}</div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ {# Action buttons #}
66
+ <div class="ap-profile__actions">
67
+ <button class="ap-profile__action ap-profile__action--follow"
68
+ :class="{ 'ap-profile__action--active': following }"
69
+ :disabled="loading"
70
+ @click="
71
+ const ok = await action(following ? 'unfollow' : 'follow', { url: '{{ actorUrl }}' });
72
+ if (ok) following = !following;
73
+ ">
74
+ <span x-text="following ? '{{ __('activitypub.profile.remote.unfollow') }}' : '{{ __('activitypub.profile.remote.follow') }}'"></span>
75
+ </button>
76
+
77
+ <button class="ap-profile__action"
78
+ :disabled="loading"
79
+ @click="
80
+ const ok = await action(muted ? 'unmute' : 'mute', { url: '{{ actorUrl }}' });
81
+ if (ok) muted = !muted;
82
+ ">
83
+ <span x-text="muted ? '{{ __('activitypub.moderation.unmute') }}' : '{{ __('activitypub.moderation.muteActor') }}'"></span>
84
+ </button>
85
+
86
+ <button class="ap-profile__action ap-profile__action--danger"
87
+ :disabled="loading"
88
+ @click="
89
+ const ok = await action(blocked ? 'unblock' : 'block', { url: '{{ actorUrl }}' });
90
+ if (ok) blocked = !blocked;
91
+ ">
92
+ <span x-text="blocked ? '{{ __('activitypub.moderation.unblock') }}' : '{{ __('activitypub.moderation.blockActor') }}'"></span>
93
+ </button>
94
+
95
+ <a href="{{ actorUrl }}" class="ap-profile__action" target="_blank" rel="noopener">
96
+ {{ __("activitypub.profile.remote.viewOn") }} {{ instanceHost }}
97
+ </a>
98
+ </div>
99
+ </div>
100
+
101
+ {# Posts from this actor #}
102
+ <div class="ap-profile__posts">
103
+ <h3>{{ __("activitypub.profile.remote.postsTitle") }}</h3>
104
+ {% if posts.length > 0 %}
105
+ <div class="ap-timeline">
106
+ {% for item in posts %}
107
+ {% include "partials/ap-item-card.njk" %}
108
+ {% endfor %}
109
+ </div>
110
+ {% elif isFollowing %}
111
+ {{ prose({ text: __("activitypub.profile.remote.noPosts") }) }}
112
+ {% else %}
113
+ {{ prose({ text: __("activitypub.profile.remote.followToSee") }) }}
114
+ {% endif %}
115
+ </div>
116
+ </div>
117
+ {% endblock %}