@commonpub/layer 0.6.2 → 0.7.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.
@@ -10,7 +10,9 @@ interface Comment {
10
10
  id: string;
11
11
  content: string;
12
12
  createdAt: string;
13
+ parentId: string | null;
13
14
  author: CommentAuthor | null;
15
+ replies?: Comment[];
14
16
  }
15
17
 
16
18
  const props = defineProps<{
@@ -64,8 +66,23 @@ async function loadMoreComments(): Promise<void> {
64
66
  }
65
67
  }
66
68
 
69
+ // Reply state
67
70
  const newComment = ref('');
68
71
  const submitting = ref(false);
72
+ const replyingTo = ref<{ id: string; username: string } | null>(null);
73
+
74
+ function startReply(comment: Comment): void {
75
+ replyingTo.value = {
76
+ id: comment.id,
77
+ username: comment.author?.username ?? 'someone',
78
+ };
79
+ newComment.value = `@${comment.author?.username ?? 'someone'} `;
80
+ }
81
+
82
+ function cancelReply(): void {
83
+ replyingTo.value = null;
84
+ newComment.value = '';
85
+ }
69
86
 
70
87
  async function submitComment(): Promise<void> {
71
88
  if (!newComment.value.trim()) return;
@@ -87,10 +104,12 @@ async function submitComment(): Promise<void> {
87
104
  targetType: props.targetType,
88
105
  targetId: props.targetId,
89
106
  content: newComment.value,
107
+ parentId: replyingTo.value?.id || undefined,
90
108
  },
91
109
  });
92
110
  }
93
111
  newComment.value = '';
112
+ replyingTo.value = null;
94
113
  if (props.federatedContentId) {
95
114
  replySent.value = true;
96
115
  setTimeout(() => { replySent.value = false; }, 5000);
@@ -121,10 +140,14 @@ async function deleteComment(id: string): Promise<void> {
121
140
 
122
141
  <!-- New comment form -->
123
142
  <div v-if="user" class="cpub-comment-form">
143
+ <div v-if="replyingTo" class="cpub-replying-to">
144
+ Replying to @{{ replyingTo.username }}
145
+ <button class="cpub-cancel-reply" @click="cancelReply"><i class="fa-solid fa-xmark"></i></button>
146
+ </div>
124
147
  <textarea
125
148
  v-model="newComment"
126
149
  class="cpub-textarea"
127
- :placeholder="isFederated ? 'Write a reply (will be sent to the original instance)...' : 'Write a comment...'"
150
+ :placeholder="replyingTo ? `Reply to @${replyingTo.username}...` : isFederated ? 'Write a reply (will be sent to the original instance)...' : 'Write a comment...'"
128
151
  rows="3"
129
152
  aria-label="Write a comment"
130
153
  ></textarea>
@@ -133,7 +156,7 @@ async function deleteComment(id: string): Promise<void> {
133
156
  :disabled="!newComment.trim() || submitting"
134
157
  @click="submitComment"
135
158
  >
136
- {{ submitting ? 'Posting...' : isFederated ? 'Send Reply' : 'Post Comment' }}
159
+ {{ submitting ? 'Posting...' : replyingTo ? 'Reply' : isFederated ? 'Send Reply' : 'Post Comment' }}
137
160
  </button>
138
161
  </div>
139
162
  <p v-else class="cpub-comment-login">
@@ -145,33 +168,72 @@ async function deleteComment(id: string): Promise<void> {
145
168
  <i class="fa-solid fa-check-circle"></i> Reply sent to the original instance.
146
169
  </div>
147
170
 
148
- <!-- Comments list -->
171
+ <!-- Comments list (threaded) -->
149
172
  <div class="cpub-comment-list">
150
- <div v-for="comment in (comments || [])" :key="comment.id" class="cpub-comment">
151
- <div class="cpub-comment-avatar">
152
- <img v-if="comment.author?.avatarUrl" :src="comment.author.avatarUrl" :alt="comment.author?.displayName || comment.author?.username" class="cpub-comment-avatar-img" />
153
- <span v-else>{{ (comment.author?.displayName || comment.author?.username || 'U').charAt(0).toUpperCase() }}</span>
173
+ <template v-for="comment in (comments || [])" :key="comment.id">
174
+ <!-- Root comment -->
175
+ <div class="cpub-comment">
176
+ <div class="cpub-comment-avatar">
177
+ <img v-if="comment.author?.avatarUrl" :src="comment.author.avatarUrl" :alt="comment.author?.displayName || comment.author?.username" class="cpub-comment-avatar-img" />
178
+ <span v-else>{{ (comment.author?.displayName || comment.author?.username || 'U').charAt(0).toUpperCase() }}</span>
179
+ </div>
180
+ <div class="cpub-comment-body">
181
+ <div class="cpub-comment-header">
182
+ <NuxtLink :to="`/u/${comment.author?.username}`" class="cpub-comment-author">
183
+ {{ comment.author?.displayName || comment.author?.username }}
184
+ </NuxtLink>
185
+ <time class="cpub-comment-time">
186
+ {{ new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
187
+ </time>
188
+ </div>
189
+ <p class="cpub-comment-text"><MentionText :text="comment.content" /></p>
190
+ <div class="cpub-comment-actions">
191
+ <button v-if="user && !isFederated" class="cpub-comment-action-btn" @click="startReply(comment)">
192
+ <i class="fa-solid fa-reply"></i> Reply
193
+ </button>
194
+ <button
195
+ v-if="user?.id === comment.author?.id"
196
+ class="cpub-comment-delete"
197
+ @click="deleteComment(comment.id)"
198
+ aria-label="Delete comment"
199
+ >
200
+ Delete
201
+ </button>
202
+ </div>
203
+ </div>
154
204
  </div>
155
- <div class="cpub-comment-body">
156
- <div class="cpub-comment-header">
157
- <NuxtLink :to="`/u/${comment.author?.username}`" class="cpub-comment-author">
158
- {{ comment.author?.displayName || comment.author?.username }}
159
- </NuxtLink>
160
- <time class="cpub-comment-time">
161
- {{ new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
162
- </time>
205
+
206
+ <!-- Child replies (threaded) -->
207
+ <div v-if="comment.replies?.length" class="cpub-comment-thread">
208
+ <div v-for="child in comment.replies" :key="child.id" class="cpub-comment cpub-comment-nested">
209
+ <div class="cpub-comment-avatar">
210
+ <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-comment-avatar-img" />
211
+ <span v-else>{{ (child.author?.displayName || child.author?.username || 'U').charAt(0).toUpperCase() }}</span>
212
+ </div>
213
+ <div class="cpub-comment-body">
214
+ <div class="cpub-comment-header">
215
+ <NuxtLink :to="`/u/${child.author?.username}`" class="cpub-comment-author">
216
+ {{ child.author?.displayName || child.author?.username }}
217
+ </NuxtLink>
218
+ <time class="cpub-comment-time">
219
+ {{ new Date(child.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }}
220
+ </time>
221
+ </div>
222
+ <p class="cpub-comment-text"><MentionText :text="child.content" /></p>
223
+ <div class="cpub-comment-actions">
224
+ <button
225
+ v-if="user?.id === child.author?.id"
226
+ class="cpub-comment-delete"
227
+ @click="deleteComment(child.id)"
228
+ aria-label="Delete comment"
229
+ >
230
+ Delete
231
+ </button>
232
+ </div>
233
+ </div>
163
234
  </div>
164
- <p class="cpub-comment-text"><MentionText :text="comment.content" /></p>
165
- <button
166
- v-if="user?.id === comment.author?.id"
167
- class="cpub-comment-delete"
168
- @click="deleteComment(comment.id)"
169
- aria-label="Delete comment"
170
- >
171
- Delete
172
- </button>
173
235
  </div>
174
- </div>
236
+ </template>
175
237
  <p v-if="!comments?.length" class="cpub-comments-empty">{{ isFederated ? 'No replies received yet.' : 'No comments yet. Be the first!' }}</p>
176
238
  <div v-if="hasMore" class="cpub-comments-more">
177
239
  <button class="cpub-btn cpub-btn-sm" :disabled="loadingMore" @click="loadMoreComments">
@@ -219,6 +281,29 @@ async function deleteComment(id: string): Promise<void> {
219
281
  width: 100%;
220
282
  }
221
283
 
284
+ .cpub-replying-to {
285
+ width: 100%;
286
+ font-size: 12px;
287
+ color: var(--text-dim);
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 8px;
291
+ padding: 6px 10px;
292
+ background: var(--surface2);
293
+ border: 1px solid var(--border);
294
+ }
295
+
296
+ .cpub-cancel-reply {
297
+ margin-left: auto;
298
+ background: none;
299
+ border: none;
300
+ color: var(--text-faint);
301
+ cursor: pointer;
302
+ padding: 0 4px;
303
+ font-size: 12px;
304
+ }
305
+ .cpub-cancel-reply:hover { color: var(--text); }
306
+
222
307
  .cpub-comment-fed-notice {
223
308
  font-size: 12px;
224
309
  color: var(--text-dim);
@@ -250,6 +335,19 @@ async function deleteComment(id: string): Promise<void> {
250
335
  gap: 10px;
251
336
  }
252
337
 
338
+ .cpub-comment-thread {
339
+ padding-left: 38px;
340
+ border-left: 2px solid var(--border);
341
+ margin-left: 14px;
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: var(--space-3);
345
+ }
346
+
347
+ .cpub-comment-nested {
348
+ padding-left: 0;
349
+ }
350
+
253
351
  .cpub-comment-avatar {
254
352
  width: 28px;
255
353
  height: 28px;
@@ -309,13 +407,32 @@ async function deleteComment(id: string): Promise<void> {
309
407
  line-height: 1.6;
310
408
  }
311
409
 
410
+ .cpub-comment-actions {
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 12px;
414
+ margin-top: 4px;
415
+ }
416
+
417
+ .cpub-comment-action-btn {
418
+ font-size: 10px;
419
+ color: var(--text-faint);
420
+ background: none;
421
+ border: none;
422
+ cursor: pointer;
423
+ padding: 0;
424
+ display: flex;
425
+ align-items: center;
426
+ gap: 4px;
427
+ }
428
+ .cpub-comment-action-btn:hover { color: var(--accent); }
429
+
312
430
  .cpub-comment-delete {
313
431
  font-size: 10px;
314
432
  color: var(--text-faint);
315
433
  background: none;
316
434
  border: none;
317
435
  cursor: pointer;
318
- margin-top: 4px;
319
436
  padding: 0;
320
437
  }
321
438
 
@@ -1,25 +1,52 @@
1
1
  <script setup lang="ts">
2
2
  import type { HubMemberViewModel } from '../../types/hub';
3
3
 
4
+ export interface RemoteMemberVM {
5
+ actorUri: string;
6
+ name: string;
7
+ instanceDomain: string;
8
+ avatarUrl: string | null;
9
+ }
10
+
4
11
  defineProps<{
5
- members: HubMemberViewModel[]
12
+ members: HubMemberViewModel[];
13
+ remoteMembers?: RemoteMemberVM[];
6
14
  }>();
7
15
  </script>
8
16
 
9
17
  <template>
10
- <div v-if="members?.length" class="cpub-members-grid">
11
- <MemberCard
12
- v-for="member in members"
13
- :key="member.username"
14
- :username="member.username"
15
- :display-name="member.name"
16
- :role="(member.role as 'owner' | 'moderator' | 'member') || 'member'"
17
- :joined-at="new Date(member.joinedAt)"
18
- />
19
- </div>
20
- <div v-else class="cpub-empty-state">
21
- <div class="cpub-empty-state-icon"><i class="fa-solid fa-users"></i></div>
22
- <p class="cpub-empty-state-title">No members yet</p>
18
+ <div>
19
+ <div v-if="members?.length" class="cpub-members-grid">
20
+ <MemberCard
21
+ v-for="member in members"
22
+ :key="member.username"
23
+ :username="member.username"
24
+ :display-name="member.name"
25
+ :role="(member.role as 'owner' | 'moderator' | 'member') || 'member'"
26
+ :joined-at="new Date(member.joinedAt)"
27
+ />
28
+ </div>
29
+
30
+ <div v-if="remoteMembers?.length" class="cpub-remote-members-section">
31
+ <h4 class="cpub-remote-members-title"><i class="fa-solid fa-globe"></i> Federated Members</h4>
32
+ <div class="cpub-members-grid">
33
+ <div v-for="rm in remoteMembers" :key="rm.actorUri" class="cpub-remote-member-card">
34
+ <div class="cpub-remote-member-avatar">
35
+ <img v-if="rm.avatarUrl" :src="rm.avatarUrl" :alt="rm.name" class="cpub-remote-member-avatar-img" />
36
+ <span v-else>{{ rm.name.charAt(0).toUpperCase() }}</span>
37
+ </div>
38
+ <div class="cpub-remote-member-info">
39
+ <span class="cpub-remote-member-name">{{ rm.name }}</span>
40
+ <span class="cpub-remote-member-domain">{{ rm.instanceDomain }}</span>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div v-if="!members?.length && !remoteMembers?.length" class="cpub-empty-state">
47
+ <div class="cpub-empty-state-icon"><i class="fa-solid fa-users"></i></div>
48
+ <p class="cpub-empty-state-title">No members yet</p>
49
+ </div>
23
50
  </div>
24
51
  </template>
25
52
 
@@ -30,6 +57,29 @@ defineProps<{
30
57
  gap: 12px;
31
58
  }
32
59
 
60
+ .cpub-remote-members-section { margin-top: 24px; }
61
+ .cpub-remote-members-title {
62
+ font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
63
+ letter-spacing: 0.05em; color: var(--text-dim); margin-bottom: 12px;
64
+ display: flex; align-items: center; gap: 6px;
65
+ }
66
+ .cpub-remote-members-title > i { color: var(--accent); }
67
+
68
+ .cpub-remote-member-card {
69
+ display: flex; align-items: center; gap: 10px;
70
+ padding: 10px 12px; background: var(--surface);
71
+ border: var(--border-width-default) solid var(--border);
72
+ }
73
+ .cpub-remote-member-avatar {
74
+ width: 32px; height: 32px; border-radius: 50%; overflow: hidden;
75
+ background: var(--accent-bg); display: flex; align-items: center; justify-content: center;
76
+ font-size: 13px; font-weight: 600; color: var(--accent); flex-shrink: 0;
77
+ }
78
+ .cpub-remote-member-avatar-img { width: 100%; height: 100%; object-fit: cover; }
79
+ .cpub-remote-member-info { display: flex; flex-direction: column; min-width: 0; }
80
+ .cpub-remote-member-name { font-size: 13px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
+ .cpub-remote-member-domain { font-size: 11px; color: var(--text-dim); }
82
+
33
83
  @media (max-width: 1024px) {
34
84
  .cpub-members-grid { grid-template-columns: repeat(2, 1fr); }
35
85
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -50,16 +50,16 @@
50
50
  "vue": "^3.4.0",
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/docs": "0.6.0",
54
53
  "@commonpub/auth": "0.5.0",
55
54
  "@commonpub/config": "0.8.0",
56
55
  "@commonpub/editor": "0.5.0",
57
56
  "@commonpub/learning": "0.5.0",
58
- "@commonpub/protocol": "0.9.7",
59
- "@commonpub/server": "2.26.0",
60
- "@commonpub/explainer": "0.7.1",
61
- "@commonpub/schema": "0.8.18",
62
- "@commonpub/ui": "0.8.4"
57
+ "@commonpub/docs": "0.6.1",
58
+ "@commonpub/schema": "0.9.1",
59
+ "@commonpub/ui": "0.8.4",
60
+ "@commonpub/explainer": "0.7.2",
61
+ "@commonpub/server": "2.27.1",
62
+ "@commonpub/protocol": "0.9.7"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -202,9 +202,23 @@ async function handleDiscPost(): Promise<void> {
202
202
  }
203
203
  }
204
204
 
205
- // --- Instance mirror status (not user-level follow) ---
205
+ // --- Instance mirror status ---
206
206
  const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
207
207
 
208
+ // --- Per-user join state (only fetch when authenticated) ---
209
+ const userFollowState = ref<{ joined: boolean; status: string | null }>({ joined: false, status: null });
210
+ const userJoined = computed(() => userFollowState.value?.joined ?? false);
211
+
212
+ async function refreshFollowState(): Promise<void> {
213
+ if (!isAuthenticated.value) return;
214
+ try {
215
+ userFollowState.value = await $fetch<{ joined: boolean; status: string | null }>(
216
+ `/api/federation/hub-follow-status?federatedHubId=${id}`,
217
+ );
218
+ } catch { /* best-effort */ }
219
+ }
220
+ onMounted(() => { refreshFollowState(); });
221
+
208
222
  const remoteFollowRef = ref<{ show: () => void } | null>(null);
209
223
  const hubFollowing = ref(false);
210
224
  const hubFollowStatus = computed(() => hub.value?.followStatus ?? '');
@@ -219,8 +233,8 @@ async function handleJoinHub(): Promise<void> {
219
233
  method: 'POST',
220
234
  body: { federatedHubId: hub.value.id },
221
235
  });
222
- toast.success(result.status === 'accepted' ? 'Now following this hub' : 'Follow request sent — it may take a moment to be accepted');
223
- await refreshHub();
236
+ toast.success(result.status === 'joined' ? 'Now following this hub' : 'Follow request sent — it may take a moment to be accepted');
237
+ await Promise.all([refreshHub(), refreshFollowState()]);
224
238
  } catch (err: unknown) {
225
239
  const msg = err instanceof Error ? err.message : 'Failed to follow hub';
226
240
  toast.error(msg);
@@ -298,12 +312,12 @@ async function handlePostVote(postId: string): Promise<void> {
298
312
  <span v-if="mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-mirrored">
299
313
  <i class="fa-solid fa-globe"></i> Mirrored
300
314
  </span>
301
- <button v-if="hub?.actorUri && mirrorStatus !== 'accepted'" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing || hubFollowStatus === 'pending'" @click="handleJoinHub">
302
- <i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : hubFollowStatus === 'pending' ? 'Follow pending...' : 'Join from your instance' }}
303
- </button>
304
- <span v-else-if="hub?.actorUri && mirrorStatus === 'accepted'" class="cpub-member-badge cpub-member-badge-joined">
315
+ <span v-if="userJoined" class="cpub-member-badge cpub-member-badge-joined">
305
316
  <i class="fa-solid fa-check"></i> Joined
306
317
  </span>
318
+ <button v-else-if="hub?.actorUri" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="hubFollowing || userFollowState?.status === 'pending'" @click="handleJoinHub">
319
+ <i class="fa-solid fa-user-plus"></i> {{ hubFollowing ? 'Following...' : userFollowState?.status === 'pending' ? 'Follow pending...' : 'Join Hub' }}
320
+ </button>
307
321
  <a v-if="hub?.url" :href="hub.url" target="_blank" rel="noopener noreferrer" class="cpub-btn cpub-btn-sm">
308
322
  <i class="fa-solid fa-arrow-up-right-from-square"></i> Visit original
309
323
  </a>
@@ -5,10 +5,16 @@ import type { HubViewModel, HubPostViewModel, HubMemberViewModel, HubTabDef } fr
5
5
  const route = useRoute();
6
6
  const slug = computed(() => route.params.slug as string);
7
7
 
8
+ function remoteDomain(uri: string | undefined): string | null {
9
+ if (!uri) return null;
10
+ try { return new URL(uri).hostname; } catch { return 'fediverse'; }
11
+ }
12
+
13
+
8
14
  // --- Data fetching (unchanged) ---
9
15
  const { data: hub, pending: hubPending, error: hubError, refresh: refreshHub } = useLazyFetch<Serialized<HubDetail>>(() => `/api/hubs/${slug.value}`);
10
16
  const { data: posts, refresh: refreshPosts } = useLazyFetch<Serialized<PaginatedResponse<HubPostItem>>>(() => `/api/hubs/${slug.value}/posts`, { default: () => ({ items: [], total: 0 }) });
11
- const { data: membersData } = useLazyFetch<{ items: Serialized<HubMemberItem>[]; total: number }>(() => `/api/hubs/${slug.value}/members`);
17
+ const { data: membersData } = useLazyFetch<{ items: Serialized<HubMemberItem>[]; total: number; remoteMembers?: Array<{ followerActorUri: string; displayName: string | null; preferredUsername: string | null; avatarUrl: string | null; instanceDomain: string; joinedAt: string }> }>(() => `/api/hubs/${slug.value}/members`);
12
18
  const { data: gallery, refresh: refreshGallery } = useLazyFetch<PaginatedResponse<Serialized<ContentListItem>>>(() => `/api/hubs/${slug.value}/gallery`, { default: () => ({ items: [], total: 0 }) });
13
19
 
14
20
  const hubType = computed(() => hub.value?.hubType ?? 'community');
@@ -55,8 +61,8 @@ const postsVM = computed<HubPostViewModel[]>(() => {
55
61
  type: p.type,
56
62
  content: p.content || '',
57
63
  author: {
58
- name: p.author?.displayName || p.author?.username || 'Unknown',
59
- handle: null,
64
+ name: p.author?.displayName || p.author?.username || p.remoteActorName || 'Unknown',
65
+ handle: p.author ? null : remoteDomain(p.remoteActorUri ?? undefined),
60
66
  avatarUrl: p.author?.avatarUrl ?? null,
61
67
  },
62
68
  createdAt: p.createdAt,
@@ -89,6 +95,17 @@ const membersVM = computed<HubMemberViewModel[]>(() => {
89
95
  }));
90
96
  });
91
97
 
98
+ const remoteMembersVM = computed(() => {
99
+ const raw = membersData.value?.remoteMembers;
100
+ if (!raw?.length) return [];
101
+ return raw.map(rm => ({
102
+ actorUri: rm.followerActorUri,
103
+ name: rm.displayName || rm.preferredUsername || 'Unknown',
104
+ instanceDomain: rm.instanceDomain,
105
+ avatarUrl: rm.avatarUrl,
106
+ }));
107
+ });
108
+
92
109
  const moderators = computed(() => {
93
110
  return membersVM.value.filter((m) => m.role === 'owner' || m.role === 'moderator');
94
111
  });
@@ -333,7 +350,7 @@ async function onRefreshGallery(): Promise<void> {
333
350
  </HubDiscussions>
334
351
 
335
352
  <!-- Members tab -->
336
- <HubMembers v-else-if="activeTab === 'members'" :members="membersVM" />
353
+ <HubMembers v-else-if="activeTab === 'members'" :members="membersVM" :remote-members="remoteMembersVM" />
337
354
 
338
355
  <!-- Overview tab -->
339
356
  <template v-else-if="activeTab === 'overview'">
@@ -122,6 +122,11 @@ async function saveEdit(): Promise<void> {
122
122
  }
123
123
  }
124
124
 
125
+ function replyDisplayName(reply: { author?: { displayName?: string | null; username?: string } | null; remoteActorName?: string | null }): string {
126
+ if (reply.author) return reply.author.displayName || reply.author.username || 'U';
127
+ return reply.remoteActorName || 'Someone';
128
+ }
129
+
125
130
  function formatDate(d: string | Date): string {
126
131
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
127
132
  }
@@ -171,9 +176,12 @@ useSeoMeta({
171
176
  <div class="cpub-post-author">
172
177
  <div class="cpub-post-avatar">
173
178
  <img v-if="post.author?.avatarUrl" :src="post.author.avatarUrl" :alt="post.author?.displayName || post.author?.username" class="cpub-post-avatar-img" />
174
- <span v-else>{{ (post.author?.displayName || post.author?.username || 'U').charAt(0).toUpperCase() }}</span>
179
+ <span v-else>{{ (post.author?.displayName || post.author?.username || post.remoteActorName || 'U').charAt(0).toUpperCase() }}</span>
175
180
  </div>
176
- <NuxtLink :to="`/u/${post.author?.username}`" class="cpub-post-author-name">{{ post.author?.displayName || post.author?.username }}</NuxtLink>
181
+ <NuxtLink v-if="post.author" :to="`/u/${post.author.username}`" class="cpub-post-author-name">{{ post.author.displayName || post.author.username }}</NuxtLink>
182
+ <span v-else class="cpub-post-author-name cpub-reply-remote">
183
+ <i class="fa-solid fa-globe" title="Federated post"></i> {{ post.remoteActorName || 'Someone' }}
184
+ </span>
177
185
  <span class="cpub-post-sep">&middot;</span>
178
186
  <time class="cpub-post-time">{{ formatDate(post.createdAt) }}</time>
179
187
  </div>
@@ -229,15 +237,18 @@ useSeoMeta({
229
237
  <div class="cpub-reply-author">
230
238
  <div class="cpub-reply-avatar">
231
239
  <img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
232
- <span v-else>{{ (reply.author?.displayName || reply.author?.username || 'U').charAt(0).toUpperCase() }}</span>
240
+ <span v-else>{{ (replyDisplayName(reply)).charAt(0).toUpperCase() }}</span>
233
241
  </div>
234
- <NuxtLink :to="`/u/${reply.author?.username}`" class="cpub-reply-author-name">{{ reply.author?.displayName || reply.author?.username }}</NuxtLink>
242
+ <NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
243
+ <span v-else class="cpub-reply-author-name cpub-reply-remote">
244
+ <i class="fa-solid fa-globe" title="Federated reply"></i> {{ reply.remoteActorName || 'Someone' }}
245
+ </span>
235
246
  <span class="cpub-post-sep">&middot;</span>
236
247
  <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
237
248
  </div>
238
249
  <div class="cpub-reply-content"><MentionText :text="reply.content" /></div>
239
250
  <div class="cpub-reply-actions">
240
- <button v-if="isAuthenticated && hub?.currentUserRole && !post.isLocked" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username} `">
251
+ <button v-if="isAuthenticated && hub?.currentUserRole && !post.isLocked" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username ?? reply.remoteActorName ?? ''} `">
241
252
  <i class="fa-solid fa-reply"></i> Reply
242
253
  </button>
243
254
  </div>
@@ -248,9 +259,12 @@ useSeoMeta({
248
259
  <div class="cpub-reply-author">
249
260
  <div class="cpub-reply-avatar">
250
261
  <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
251
- <span v-else>{{ (child.author?.displayName || child.author?.username || 'U').charAt(0).toUpperCase() }}</span>
262
+ <span v-else>{{ (replyDisplayName(child)).charAt(0).toUpperCase() }}</span>
252
263
  </div>
253
- <NuxtLink :to="`/u/${child.author?.username}`" class="cpub-reply-author-name">{{ child.author?.displayName || child.author?.username }}</NuxtLink>
264
+ <NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
265
+ <span v-else class="cpub-reply-author-name cpub-reply-remote">
266
+ <i class="fa-solid fa-globe" title="Federated reply"></i> {{ child.remoteActorName || 'Someone' }}
267
+ </span>
254
268
  <span class="cpub-post-sep">&middot;</span>
255
269
  <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
256
270
  </div>
@@ -403,6 +417,8 @@ useSeoMeta({
403
417
 
404
418
  .cpub-reply-author-name { font-weight: 500; color: var(--text-dim); text-decoration: none; }
405
419
  .cpub-reply-author-name:hover { color: var(--accent); }
420
+ .cpub-reply-remote { display: inline-flex; align-items: center; gap: 4px; }
421
+ .cpub-reply-remote > i { font-size: 10px; color: var(--accent); }
406
422
 
407
423
  .cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
408
424
 
@@ -19,16 +19,8 @@ export default defineEventHandler(async (event) => {
19
19
  const page = pages.find((p) => p.slug === pageSlug);
20
20
  if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' });
21
21
 
22
- // Handle dual-format content: BlockTuple[] (new) or markdown string (legacy)
23
- // Content is stored as TEXT — JSON arrays come back as strings, need parsing
24
- let content: string | unknown[] = page.content ?? '';
25
- if (typeof content === 'string' && content.trimStart().startsWith('[')) {
26
- try {
27
- content = JSON.parse(content);
28
- } catch {
29
- // Not valid JSON — keep as markdown string
30
- }
31
- }
22
+ // Content is JSONB arrays come back parsed, legacy strings stay as strings
23
+ const content = page.content ?? '';
32
24
 
33
25
  if (Array.isArray(content)) {
34
26
  // New BlockTuple format — extract text for TOC generation
@@ -32,16 +32,9 @@ export default defineEventHandler(async (event) => {
32
32
 
33
33
  const pages = await listDocsPages(db, version.id);
34
34
 
35
- // Parse content: if stored as JSON string (BlockTuple[]), parse back to array
35
+ // Content is JSONB arrays come back parsed, legacy strings stay as strings
36
36
  return pages.map((page) => {
37
- let content: string | unknown[] = page.content ?? '';
38
- if (typeof content === 'string' && content.startsWith('[')) {
39
- try {
40
- content = JSON.parse(content);
41
- } catch {
42
- // Not valid JSON — keep as markdown string
43
- }
44
- }
37
+ const content = page.content ?? '';
45
38
  return { ...page, content };
46
39
  });
47
40
  });
@@ -0,0 +1,37 @@
1
+ import { userFederatedHubFollows } from '@commonpub/schema';
2
+ import { eq, and } from 'drizzle-orm';
3
+ import { z } from 'zod';
4
+
5
+ const querySchema = z.object({
6
+ federatedHubId: z.string().uuid(),
7
+ });
8
+
9
+ export default defineEventHandler(async (event): Promise<{ joined: boolean; status: string | null }> => {
10
+ requireFeature('federation');
11
+ requireFeature('federateHubs');
12
+
13
+ const user = getOptionalUser(event);
14
+ if (!user) {
15
+ return { joined: false, status: null };
16
+ }
17
+
18
+ const db = useDB();
19
+ const { federatedHubId } = parseQueryParams(event, querySchema);
20
+
21
+ const [record] = await db
22
+ .select({ status: userFederatedHubFollows.status })
23
+ .from(userFederatedHubFollows)
24
+ .where(
25
+ and(
26
+ eq(userFederatedHubFollows.userId, user.id),
27
+ eq(userFederatedHubFollows.federatedHubId, federatedHubId),
28
+ ),
29
+ )
30
+ .limit(1);
31
+
32
+ if (!record) {
33
+ return { joined: false, status: null };
34
+ }
35
+
36
+ return { joined: record.status === 'joined', status: record.status };
37
+ });
@@ -1,4 +1,6 @@
1
1
  import { sendHubFollow, getFederatedHub } from '@commonpub/server';
2
+ import { userFederatedHubFollows } from '@commonpub/schema';
3
+ import { eq, and } from 'drizzle-orm';
2
4
  import { z } from 'zod';
3
5
 
4
6
  const schema = z.object({
@@ -8,7 +10,7 @@ const schema = z.object({
8
10
  export default defineEventHandler(async (event): Promise<{ success: boolean; status: string }> => {
9
11
  requireFeature('federation');
10
12
  requireFeature('federateHubs');
11
- requireAuth(event);
13
+ const user = requireAuth(event);
12
14
  const db = useDB();
13
15
  const config = useConfig();
14
16
  const { federatedHubId } = await parseBody(event, schema);
@@ -18,10 +20,24 @@ export default defineEventHandler(async (event): Promise<{ success: boolean; sta
18
20
  throw createError({ statusCode: 404, statusMessage: 'Federated hub not found' });
19
21
  }
20
22
 
21
- if (hub.followStatus === 'accepted') {
22
- return { success: true, status: 'accepted' };
23
+ // Create per-user follow record (upsert)
24
+ const userStatus = hub.followStatus === 'accepted' ? 'joined' : 'pending';
25
+ await db
26
+ .insert(userFederatedHubFollows)
27
+ .values({
28
+ userId: user.id,
29
+ federatedHubId,
30
+ status: userStatus,
31
+ })
32
+ .onConflictDoUpdate({
33
+ target: [userFederatedHubFollows.userId, userFederatedHubFollows.federatedHubId],
34
+ set: { status: userStatus, joinedAt: new Date() },
35
+ });
36
+
37
+ // Send instance-level Follow if not already accepted
38
+ if (hub.followStatus !== 'accepted') {
39
+ await sendHubFollow(db, hub.actorUri, config.instance.domain);
23
40
  }
24
41
 
25
- await sendHubFollow(db, hub.actorUri, config.instance.domain);
26
- return { success: true, status: 'pending' };
42
+ return { success: true, status: userStatus };
27
43
  });
@@ -68,8 +68,7 @@ export default defineEventHandler(async (event) => {
68
68
  const ext = contentType.split('/')[1] || 'jpg';
69
69
  const key = generateStorageKey(purpose, ext);
70
70
 
71
- await (storage as any).put(key, buffer, contentType);
72
- const resultUrl = (storage as any).getPublicUrl(key);
71
+ const resultUrl = await storage.upload(key, buffer, contentType);
73
72
 
74
73
  return { url: resultUrl };
75
74
  });
@@ -1,5 +1,5 @@
1
- import { listMembers, getHubBySlug } from '@commonpub/server';
2
- import type { HubMemberItem } from '@commonpub/server';
1
+ import { listMembers, listRemoteMembers, getHubBySlug } from '@commonpub/server';
2
+ import type { HubMemberItem, RemoteHubMember } from '@commonpub/server';
3
3
  import { z } from 'zod';
4
4
 
5
5
  const membersQuerySchema = z.object({
@@ -7,8 +7,9 @@ const membersQuerySchema = z.object({
7
7
  offset: z.coerce.number().int().min(0).optional(),
8
8
  });
9
9
 
10
- export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number }> => {
10
+ export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number; remoteMembers?: RemoteHubMember[] }> => {
11
11
  const db = useDB();
12
+ const config = useConfig();
12
13
  const { slug } = parseParams(event, { slug: 'string' });
13
14
  const query = parseQueryParams(event, membersQuerySchema);
14
15
  const community = await getHubBySlug(db, slug);
@@ -16,5 +17,13 @@ export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[
16
17
  throw createError({ statusCode: 404, statusMessage: 'Community not found' });
17
18
  }
18
19
 
19
- return listMembers(db, community.id, query);
20
+ const result = await listMembers(db, community.id, query);
21
+
22
+ // Include remote followers if hub federation is enabled
23
+ if (config.features.federation && config.features.federateHubs) {
24
+ const remoteMembers = await listRemoteMembers(db, community.id);
25
+ return { ...result, remoteMembers };
26
+ }
27
+
28
+ return result;
20
29
  });
@@ -76,6 +76,9 @@ export default defineNitroPlugin((nitro) => {
76
76
  }
77
77
  }, 5_000);
78
78
 
79
+ // Track the last date digests were sent to prevent duplicates on server restart during 8am hour
80
+ let lastDigestDate = '';
81
+
79
82
  async function runDigest(siteUrl: string, siteName: string): Promise<void> {
80
83
  try {
81
84
  const db = useDB();
@@ -88,6 +91,11 @@ export default defineNitroPlugin((nitro) => {
88
91
 
89
92
  if (!isDigestHour) return;
90
93
 
94
+ // Prevent duplicate sends if server restarts during digest hour
95
+ const todayKey = now.toISOString().slice(0, 10);
96
+ if (lastDigestDate === todayKey) return;
97
+ lastDigestDate = todayKey;
98
+
91
99
  // Find users with digest preferences
92
100
  const digestUsers = await db
93
101
  .select({
@@ -1,36 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Legacy edit route — redirects to /u/{username}/{type}/{slug}/edit.
4
- * For "new" content, redirects using the current user's username.
5
- */
6
- definePageMeta({ layout: false, middleware: 'auth' });
7
-
8
- const route = useRoute();
9
- const contentType = computed(() => route.params.type as string);
10
- const slug = computed(() => route.params.slug as string);
11
- const { user } = useAuth();
12
-
13
- if (slug.value === 'new') {
14
- // Creating new content — redirect to new URL using current user
15
- if (user.value?.username) {
16
- await navigateTo(
17
- `/u/${user.value.username}/${contentType.value}/new/edit${route.query.hub ? `?hub=${route.query.hub}` : ''}`,
18
- { redirectCode: 301, replace: true },
19
- );
20
- }
21
- } else {
22
- // Editing existing content — look up author
23
- const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
24
- const { data } = await useFetch(() => `/api/content/${slug.value}`, { headers: reqHeaders });
25
- if (data.value) {
26
- const d = data.value as { author?: { username: string }; type: string; slug: string };
27
- if (d.author?.username) {
28
- await navigateTo(`/u/${d.author.username}/${d.type}/${d.slug}/edit`, { redirectCode: 301, replace: true });
29
- }
30
- }
31
- }
32
- </script>
33
-
34
- <template>
35
- <div class="cpub-loading" aria-live="polite">Redirecting...</div>
36
- </template>
@@ -1,34 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Legacy content route — redirects to /u/{username}/{type}/{slug}.
4
- * Kept for backwards compatibility with existing links and bookmarks.
5
- */
6
- import type { Serialized, ContentDetail } from '@commonpub/server';
7
-
8
- const route = useRoute();
9
- const contentType = computed(() => route.params.type as string);
10
- const slug = computed(() => route.params.slug as string);
11
-
12
- const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
13
- const { data: content } = await useFetch<Serialized<ContentDetail>>(() => `/api/content/${slug.value}`, { headers: reqHeaders });
14
-
15
- // Redirect to new user-scoped URL if we can resolve the author
16
- if (content.value?.author?.username) {
17
- await navigateTo(`/u/${content.value.author.username}/${content.value.type}/${content.value.slug}`, { redirectCode: 301, replace: true });
18
- }
19
- </script>
20
-
21
- <template>
22
- <div v-if="!content" class="cpub-not-found">
23
- <h1>Content not found</h1>
24
- <p>The requested content could not be found.</p>
25
- </div>
26
- </template>
27
-
28
- <style scoped>
29
- .cpub-not-found {
30
- text-align: center;
31
- padding: var(--space-16) 0;
32
- color: var(--text-dim);
33
- }
34
- </style>