@commonpub/layer 0.7.1 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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/auth": "0.5.0",
54
53
  "@commonpub/config": "0.8.0",
55
- "@commonpub/editor": "0.5.0",
56
- "@commonpub/learning": "0.5.0",
57
54
  "@commonpub/docs": "0.6.1",
58
- "@commonpub/schema": "0.9.1",
59
- "@commonpub/ui": "0.8.4",
55
+ "@commonpub/editor": "0.5.0",
60
56
  "@commonpub/explainer": "0.7.2",
61
- "@commonpub/server": "2.27.1",
62
- "@commonpub/protocol": "0.9.7"
57
+ "@commonpub/auth": "0.5.0",
58
+ "@commonpub/learning": "0.5.0",
59
+ "@commonpub/protocol": "0.9.7",
60
+ "@commonpub/server": "2.27.2",
61
+ "@commonpub/schema": "0.9.2",
62
+ "@commonpub/ui": "0.8.4"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { FederatedHubListItem, FederatedHubPostItem } from '@commonpub/server';
2
+ import type { FederatedHubListItem, FederatedHubPostItem, FederatedHubPostReplyItem } from '@commonpub/server';
3
3
 
4
4
  const route = useRoute();
5
5
  const id = route.params.id as string;
@@ -10,6 +10,9 @@ const toast = useToast();
10
10
 
11
11
  const { data: hub } = useLazyFetch<FederatedHubListItem>(`/api/federated-hubs/${id}`);
12
12
  const { data: post, refresh: refreshPost } = useLazyFetch<FederatedHubPostItem>(`/api/federated-hubs/${id}/posts/${postId}`);
13
+ const { data: repliesData, refresh: refreshReplies } = useLazyFetch<{ items: FederatedHubPostReplyItem[]; total: number }>(`/api/federated-hubs/${id}/posts/${postId}/replies`, { default: () => ({ items: [], total: 0 }) });
14
+
15
+ const replies = computed(() => repliesData.value?.items ?? []);
13
16
 
14
17
  function formatDate(d: string | Date | null): string {
15
18
  if (!d) return '';
@@ -68,6 +71,7 @@ async function handleLike(): Promise<void> {
68
71
  // Reply
69
72
  const replyContent = ref('');
70
73
  const replying = ref(false);
74
+ const replyingTo = ref<string | null>(null);
71
75
 
72
76
  async function handleReply(): Promise<void> {
73
77
  if (!replyContent.value.trim()) return;
@@ -75,11 +79,12 @@ async function handleReply(): Promise<void> {
75
79
  try {
76
80
  await $fetch('/api/federation/hub-post-reply' as string, {
77
81
  method: 'POST',
78
- body: { federatedHubPostId: postId, content: replyContent.value },
82
+ body: { federatedHubPostId: postId, content: replyContent.value, parentId: replyingTo.value || undefined },
79
83
  });
80
84
  replyContent.value = '';
81
- toast.success('Reply sent via federation');
82
- await refreshPost();
85
+ replyingTo.value = null;
86
+ toast.success('Reply posted');
87
+ await Promise.all([refreshReplies(), refreshPost()]);
83
88
  } catch {
84
89
  toast.error('Failed to send reply');
85
90
  } finally {
@@ -162,12 +167,15 @@ useHead({
162
167
 
163
168
  <!-- Reply form -->
164
169
  <div v-if="isAuthenticated" class="cpub-reply-form">
170
+ <div v-if="replyingTo" class="cpub-replying-to">
171
+ Replying to a comment <button class="cpub-cancel-reply" @click="replyingTo = null"><i class="fa-solid fa-xmark"></i></button>
172
+ </div>
165
173
  <div class="cpub-reply-row">
166
174
  <input
167
175
  v-model="replyContent"
168
176
  class="cpub-reply-input"
169
177
  type="text"
170
- placeholder="Write a reply (sent via federation)..."
178
+ placeholder="Write a reply..."
171
179
  aria-label="Write a reply"
172
180
  @keydown.enter="handleReply"
173
181
  />
@@ -176,18 +184,53 @@ useHead({
176
184
  </button>
177
185
  </div>
178
186
  <p class="cpub-fed-reply-hint">
179
- <i class="fa-solid fa-globe"></i> Your reply will be sent to {{ hub?.originDomain }} via ActivityPub
187
+ <i class="fa-solid fa-globe"></i> Your reply will also be sent to {{ hub?.originDomain }} via ActivityPub
180
188
  </p>
181
189
  </div>
182
190
 
183
- <!-- Reply thread info -->
191
+ <!-- Replies -->
184
192
  <div class="cpub-replies-section">
185
- <div class="cpub-empty-state" style="padding: 32px 16px">
186
- <p class="cpub-empty-state-title"><i class="fa-solid fa-globe"></i> Federated thread</p>
193
+ <h3 v-if="replies.length" class="cpub-replies-title">{{ repliesData?.total ?? 0 }} Local Replies</h3>
194
+ <div v-for="reply in replies" :key="reply.id" class="cpub-reply">
195
+ <div class="cpub-reply-author">
196
+ <div class="cpub-reply-avatar">
197
+ <img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
198
+ <span v-else>{{ (reply.author?.displayName || reply.author?.username || 'U').charAt(0).toUpperCase() }}</span>
199
+ </div>
200
+ <NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
201
+ <span class="cpub-post-sep">&middot;</span>
202
+ <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
203
+ </div>
204
+ <div class="cpub-reply-content"><MentionText :text="reply.content" /></div>
205
+ <div class="cpub-reply-actions">
206
+ <button v-if="isAuthenticated" class="cpub-reply-btn" @click="replyingTo = reply.id; replyContent = `@${reply.author?.username ?? ''} `">
207
+ <i class="fa-solid fa-reply"></i> Reply
208
+ </button>
209
+ </div>
210
+
211
+ <!-- Nested replies -->
212
+ <div v-if="reply.replies?.length" class="cpub-nested-replies">
213
+ <div v-for="child in reply.replies" :key="child.id" class="cpub-reply cpub-reply-nested">
214
+ <div class="cpub-reply-author">
215
+ <div class="cpub-reply-avatar">
216
+ <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
217
+ <span v-else>{{ (child.author?.displayName || child.author?.username || 'U').charAt(0).toUpperCase() }}</span>
218
+ </div>
219
+ <NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
220
+ <span class="cpub-post-sep">&middot;</span>
221
+ <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
222
+ </div>
223
+ <div class="cpub-reply-content"><MentionText :text="child.content" /></div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Federation thread info -->
229
+ <div class="cpub-fed-thread-info" style="padding: 16px">
187
230
  <p class="cpub-empty-state-desc">
188
- Replies sent here are delivered to <strong>{{ hub?.originDomain }}</strong> via ActivityPub.
231
+ <i class="fa-solid fa-globe"></i>
189
232
  <a v-if="post.objectUri" :href="post.objectUri" target="_blank" rel="noopener noreferrer" class="cpub-inline-link">
190
- View full thread on origin <i class="fa-solid fa-arrow-up-right-from-square"></i>
233
+ View full thread on {{ hub?.originDomain }} <i class="fa-solid fa-arrow-up-right-from-square"></i>
191
234
  </a>
192
235
  </p>
193
236
  </div>
@@ -278,6 +321,11 @@ useHead({
278
321
 
279
322
  /* Reply form */
280
323
  .cpub-reply-form { margin-bottom: 16px; }
324
+ .cpub-replying-to {
325
+ font-size: 11px; color: var(--text-dim); margin-bottom: 6px;
326
+ display: flex; align-items: center; gap: 6px;
327
+ }
328
+ .cpub-cancel-reply { background: none; border: none; cursor: pointer; color: var(--text-faint); font-size: 12px; }
281
329
  .cpub-reply-row { display: flex; gap: 8px; }
282
330
  .cpub-reply-input {
283
331
  flex: 1; padding: 8px 12px; background: var(--surface); border: var(--border-width-default) solid var(--border);
@@ -293,8 +341,38 @@ useHead({
293
341
 
294
342
  /* Replies section */
295
343
  .cpub-replies-section {}
296
- .cpub-empty-state { text-align: center; }
297
- .cpub-empty-state-title { font-size: 14px; font-weight: 600; color: var(--text-dim); margin-bottom: 4px; }
344
+ .cpub-replies-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
345
+
346
+ .cpub-reply {
347
+ padding: 12px 16px; background: var(--surface); border: var(--border-width-default) solid var(--border);
348
+ margin-bottom: 8px;
349
+ }
350
+ .cpub-reply-nested { margin-left: 24px; border-color: var(--border2); }
351
+ .cpub-nested-replies { margin-top: 8px; }
352
+
353
+ .cpub-reply-author { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-faint); margin-bottom: 6px; }
354
+
355
+ .cpub-reply-avatar {
356
+ width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
357
+ background: var(--surface2); border: 1px solid var(--border);
358
+ font-family: var(--font-mono); font-size: 9px; font-weight: 700; color: var(--text-dim);
359
+ overflow: hidden;
360
+ }
361
+ .cpub-reply-avatar-img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
362
+
363
+ .cpub-reply-author-name { font-weight: 500; color: var(--text-dim); text-decoration: none; }
364
+ .cpub-reply-author-name:hover { color: var(--accent); }
365
+
366
+ .cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
367
+
368
+ .cpub-reply-actions { margin-top: 6px; }
369
+ .cpub-reply-btn {
370
+ background: none; border: none; cursor: pointer; font-size: 11px;
371
+ color: var(--text-faint); padding: 2px 0;
372
+ }
373
+ .cpub-reply-btn:hover { color: var(--accent); }
374
+
375
+ .cpub-fed-thread-info { text-align: center; margin-top: 8px; }
298
376
  .cpub-empty-state-desc { font-size: 12px; color: var(--text-faint); line-height: 1.5; }
299
377
  .cpub-inline-link { color: var(--accent); text-decoration: none; white-space: nowrap; }
300
378
  .cpub-inline-link:hover { text-decoration: underline; }
@@ -63,7 +63,7 @@ const postsVM = computed<HubPostViewModel[]>(() => {
63
63
  author: {
64
64
  name: p.author?.displayName || p.author?.username || p.remoteActorName || 'Unknown',
65
65
  handle: p.author ? null : remoteDomain(p.remoteActorUri ?? undefined),
66
- avatarUrl: p.author?.avatarUrl ?? null,
66
+ avatarUrl: p.author?.avatarUrl ?? p.remoteActorAvatarUrl ?? null,
67
67
  },
68
68
  createdAt: p.createdAt,
69
69
  likeCount: p.likeCount ?? 0,
@@ -127,6 +127,11 @@ function replyDisplayName(reply: { author?: { displayName?: string | null; usern
127
127
  return reply.remoteActorName || 'Someone';
128
128
  }
129
129
 
130
+ function extractDomain(uri: string | null | undefined): string {
131
+ if (!uri) return '';
132
+ try { return new URL(uri).hostname; } catch { return ''; }
133
+ }
134
+
130
135
  function formatDate(d: string | Date): string {
131
136
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
132
137
  }
@@ -176,11 +181,13 @@ useSeoMeta({
176
181
  <div class="cpub-post-author">
177
182
  <div class="cpub-post-avatar">
178
183
  <img v-if="post.author?.avatarUrl" :src="post.author.avatarUrl" :alt="post.author?.displayName || post.author?.username" class="cpub-post-avatar-img" />
184
+ <img v-else-if="post.remoteActorAvatarUrl" :src="post.remoteActorAvatarUrl" :alt="post.remoteActorName || 'Remote user'" class="cpub-post-avatar-img" />
179
185
  <span v-else>{{ (post.author?.displayName || post.author?.username || post.remoteActorName || 'U').charAt(0).toUpperCase() }}</span>
180
186
  </div>
181
187
  <NuxtLink v-if="post.author" :to="`/u/${post.author.username}`" class="cpub-post-author-name">{{ post.author.displayName || post.author.username }}</NuxtLink>
182
188
  <span v-else class="cpub-post-author-name cpub-reply-remote">
183
189
  <i class="fa-solid fa-globe" title="Federated post"></i> {{ post.remoteActorName || 'Someone' }}
190
+ <span v-if="extractDomain(post.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(post.remoteActorUri) }}</span>
184
191
  </span>
185
192
  <span class="cpub-post-sep">&middot;</span>
186
193
  <time class="cpub-post-time">{{ formatDate(post.createdAt) }}</time>
@@ -237,11 +244,13 @@ useSeoMeta({
237
244
  <div class="cpub-reply-author">
238
245
  <div class="cpub-reply-avatar">
239
246
  <img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
247
+ <img v-else-if="reply.remoteActorAvatarUrl" :src="reply.remoteActorAvatarUrl" :alt="reply.remoteActorName || 'Remote user'" class="cpub-reply-avatar-img" />
240
248
  <span v-else>{{ (replyDisplayName(reply)).charAt(0).toUpperCase() }}</span>
241
249
  </div>
242
250
  <NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
243
251
  <span v-else class="cpub-reply-author-name cpub-reply-remote">
244
252
  <i class="fa-solid fa-globe" title="Federated reply"></i> {{ reply.remoteActorName || 'Someone' }}
253
+ <span v-if="extractDomain(reply.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(reply.remoteActorUri) }}</span>
245
254
  </span>
246
255
  <span class="cpub-post-sep">&middot;</span>
247
256
  <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
@@ -259,11 +268,13 @@ useSeoMeta({
259
268
  <div class="cpub-reply-author">
260
269
  <div class="cpub-reply-avatar">
261
270
  <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
271
+ <img v-else-if="child.remoteActorAvatarUrl" :src="child.remoteActorAvatarUrl" :alt="child.remoteActorName || 'Remote user'" class="cpub-reply-avatar-img" />
262
272
  <span v-else>{{ (replyDisplayName(child)).charAt(0).toUpperCase() }}</span>
263
273
  </div>
264
274
  <NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
265
275
  <span v-else class="cpub-reply-author-name cpub-reply-remote">
266
276
  <i class="fa-solid fa-globe" title="Federated reply"></i> {{ child.remoteActorName || 'Someone' }}
277
+ <span v-if="extractDomain(child.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(child.remoteActorUri) }}</span>
267
278
  </span>
268
279
  <span class="cpub-post-sep">&middot;</span>
269
280
  <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
@@ -419,6 +430,7 @@ useSeoMeta({
419
430
  .cpub-reply-author-name:hover { color: var(--accent); }
420
431
  .cpub-reply-remote { display: inline-flex; align-items: center; gap: 4px; }
421
432
  .cpub-reply-remote > i { font-size: 10px; color: var(--accent); }
433
+ .cpub-remote-domain { font-size: 10px; color: var(--text-faint); font-weight: 400; }
422
434
 
423
435
  .cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
424
436
 
@@ -0,0 +1,13 @@
1
+ import { listFederatedHubPostReplies } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ requireFeature('federation');
5
+ const postId = getRouterParam(event, 'postId')!;
6
+ const query = getQuery(event);
7
+ const db = useDB();
8
+
9
+ return listFederatedHubPostReplies(db, postId, {
10
+ limit: query.limit ? Number(query.limit) : undefined,
11
+ offset: query.offset ? Number(query.offset) : undefined,
12
+ });
13
+ });
@@ -1,17 +1,19 @@
1
- import { sendPostToRemoteHub, getFederatedHubPost, getFederatedHub } from '@commonpub/server';
1
+ import { sendPostToRemoteHub, getFederatedHubPost, getFederatedHub, createFederatedHubPostReply } from '@commonpub/server';
2
+ import type { FederatedHubPostReplyItem } from '@commonpub/server';
2
3
  import { z } from 'zod';
3
4
 
4
5
  const replySchema = z.object({
5
6
  federatedHubPostId: z.string().uuid(),
6
7
  content: z.string().min(1).max(10000),
8
+ parentId: z.string().uuid().optional(),
7
9
  });
8
10
 
9
- export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
11
+ export default defineEventHandler(async (event): Promise<FederatedHubPostReplyItem> => {
10
12
  requireFeature('federation');
11
13
  const user = requireAuth(event);
12
14
  const db = useDB();
13
15
  const config = useConfig();
14
- const { federatedHubPostId, content } = await parseBody(event, replySchema);
16
+ const { federatedHubPostId, content, parentId } = await parseBody(event, replySchema);
15
17
 
16
18
  const post = await getFederatedHubPost(db, federatedHubPostId);
17
19
  if (!post) {
@@ -23,7 +25,15 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
23
25
  throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
24
26
  }
25
27
 
26
- const success = await sendPostToRemoteHub(
28
+ // Store locally
29
+ const reply = await createFederatedHubPostReply(db, user.id, {
30
+ federatedHubPostId,
31
+ content,
32
+ parentId,
33
+ });
34
+
35
+ // Send via AP (fire-and-forget — don't block on remote delivery)
36
+ sendPostToRemoteHub(
27
37
  db,
28
38
  user.id,
29
39
  user.username,
@@ -32,11 +42,7 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
32
42
  config.instance.domain,
33
43
  'text',
34
44
  post.objectUri,
35
- );
36
-
37
- if (!success) {
38
- throw createError({ statusCode: 502, statusMessage: 'Could not reach remote hub' });
39
- }
45
+ ).catch(() => { /* best-effort federation delivery */ });
40
46
 
41
- return { success };
47
+ return reply;
42
48
  });