@commonpub/layer 0.3.8 → 0.3.10

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,58 @@
1
+ /** Composable for real-time unread message count via SSE */
2
+ export function useMessages() {
3
+ const count = useState<number>('message-count', () => 0);
4
+ const connected = useState<boolean>('message-connected', () => false);
5
+
6
+ let eventSource: EventSource | null = null;
7
+ let retryDelay = 5000;
8
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
9
+ const MAX_RETRY_DELAY = 60_000;
10
+
11
+ function connect(): void {
12
+ if (import.meta.server || eventSource) return;
13
+
14
+ eventSource = new EventSource('/api/messages/stream');
15
+ connected.value = true;
16
+
17
+ eventSource.onopen = () => {
18
+ retryDelay = 5000;
19
+ };
20
+
21
+ eventSource.onmessage = (event) => {
22
+ try {
23
+ const data = JSON.parse(event.data) as { type: string; count?: number };
24
+ if (data.type === 'count' && typeof data.count === 'number') {
25
+ count.value = data.count;
26
+ }
27
+ } catch { /* ignore */ }
28
+ };
29
+
30
+ eventSource.onerror = () => {
31
+ connected.value = false;
32
+ const wasClosed = eventSource?.readyState === 2;
33
+ eventSource?.close();
34
+ eventSource = null;
35
+ if (wasClosed) return;
36
+ retryTimer = setTimeout(connect, retryDelay);
37
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
38
+ };
39
+ }
40
+
41
+ function disconnect(): void {
42
+ if (retryTimer) {
43
+ clearTimeout(retryTimer);
44
+ retryTimer = null;
45
+ }
46
+ eventSource?.close();
47
+ eventSource = null;
48
+ connected.value = false;
49
+ retryDelay = 5000;
50
+ }
51
+
52
+ return {
53
+ count: readonly(count),
54
+ connected: readonly(connected),
55
+ connect,
56
+ disconnect,
57
+ };
58
+ }
@@ -61,7 +61,7 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
61
61
  previewToken: null,
62
62
  parts: Array.isArray(meta?.parts) ? meta.parts as ContentViewData['parts'] : null,
63
63
  sections: null,
64
- viewCount: 0,
64
+ viewCount: (fc.localViewCount as number) ?? 0,
65
65
  likeCount: (fc.localLikeCount as number) ?? 0,
66
66
  commentCount: (fc.localCommentCount as number) ?? 0,
67
67
  forkCount: 0,
@@ -78,6 +78,8 @@ export function useMirrorContent(fedContent: Ref<Record<string, unknown> | null>
78
78
  displayName: (actor.value?.displayName as string) || (actor.value?.preferredUsername as string) || 'Unknown',
79
79
  avatarUrl: (actor.value?.avatarUrl as string) || null,
80
80
  profileUrl: (actor.value?.actorUri as string) || null,
81
+ bio: (actor.value?.summary as string) || null,
82
+ followerCount: (actor.value?.followerCount as number) ?? undefined,
81
83
  },
82
84
  buildCount: 0,
83
85
  bookmarkCount: 0,
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
3
3
  const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
4
+ const { count: unreadMessages, connect: connectMessages, disconnect: disconnectMessages } = useMessages();
4
5
  const { hubs, learning, video, docs, contests, admin, federation } = useFeatures();
5
6
  const { enabledTypeMeta } = useContentTypes();
6
7
  const runtimeConfig = useRuntimeConfig();
@@ -34,6 +35,7 @@ onMounted(async () => {
34
35
  await refreshSession();
35
36
  if (isAuthenticated.value) {
36
37
  connectNotifications();
38
+ connectMessages();
37
39
  }
38
40
  document.addEventListener('keydown', handleGlobalKeydown);
39
41
  document.addEventListener('click', handleClickOutside);
@@ -41,6 +43,7 @@ onMounted(async () => {
41
43
 
42
44
  onUnmounted(() => {
43
45
  disconnectNotifications();
46
+ disconnectMessages();
44
47
  document.removeEventListener('keydown', handleGlobalKeydown);
45
48
  document.removeEventListener('click', handleClickOutside);
46
49
  });
@@ -89,6 +92,7 @@ const userUsername = computed(() => user.value?.username ?? '');
89
92
  <template v-if="isAuthenticated">
90
93
  <NuxtLink to="/messages" class="cpub-icon-btn" title="Messages" aria-label="Messages">
91
94
  <i class="fa-solid fa-envelope"></i>
95
+ <span v-if="unreadMessages > 0" class="cpub-notif-dot" />
92
96
  </NuxtLink>
93
97
  <NuxtLink to="/notifications" class="cpub-icon-btn" title="Notifications" aria-label="Notifications">
94
98
  <i class="fa-solid fa-bell"></i>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -45,14 +45,14 @@
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
47
  "@commonpub/auth": "0.5.0",
48
- "@commonpub/config": "0.7.0",
49
48
  "@commonpub/editor": "0.5.0",
50
- "@commonpub/docs": "0.5.2",
51
49
  "@commonpub/learning": "0.5.0",
50
+ "@commonpub/schema": "0.8.10",
51
+ "@commonpub/ui": "0.7.1",
52
+ "@commonpub/config": "0.7.0",
52
53
  "@commonpub/protocol": "0.9.4",
53
- "@commonpub/server": "2.8.0",
54
- "@commonpub/schema": "0.8.9",
55
- "@commonpub/ui": "0.7.1"
54
+ "@commonpub/server": "2.9.0",
55
+ "@commonpub/docs": "0.5.2"
56
56
  },
57
57
  "scripts": {}
58
58
  }
@@ -202,7 +202,7 @@ async function handleDiscPost(): Promise<void> {
202
202
  // --- Instance mirror status (not user-level follow) ---
203
203
  const mirrorStatus = computed(() => hub.value?.followStatus ?? 'pending');
204
204
 
205
- const remoteFollowRef = ref<InstanceType<typeof RemoteFollowDialog> | null>(null);
205
+ const remoteFollowRef = ref<{ show: () => void } | null>(null);
206
206
 
207
207
  // --- Like state tracking ---
208
208
  const likedPostIds = ref<Set<string>>(new Set());
@@ -18,7 +18,7 @@ const {
18
18
  const { user } = useAuth();
19
19
  const following = ref(false);
20
20
  const followState = ref<'idle' | 'sent' | 'error'>('idle');
21
- const remoteFollowRef = ref<InstanceType<typeof RemoteFollowDialog> | null>(null);
21
+ const remoteFollowRef = ref<{ show: () => void } | null>(null);
22
22
 
23
23
  async function followAuthor(): Promise<void> {
24
24
  const uri = actor.value?.actorUri as string | undefined;
@@ -43,6 +43,16 @@ if (originUrl.value) {
43
43
  });
44
44
  }
45
45
 
46
+ /** Strip HTML tags from remote actor bio (unsanitized — XSS risk with v-html) */
47
+ function stripHtml(html: string): string {
48
+ return html.replace(/<[^>]*>/g, '').trim();
49
+ }
50
+
51
+ // Track view
52
+ onMounted(() => {
53
+ $fetch(`/api/federation/content/${id}/view`, { method: 'POST' }).catch(() => {});
54
+ });
55
+
46
56
  useSeoMeta({
47
57
  title: transformedContent.value?.title ?? 'Mirrored Content',
48
58
  description: transformedContent.value?.description ?? '',
@@ -99,8 +109,13 @@ useSeoMeta({
99
109
  <h1 class="cpub-mirror-title">{{ transformedContent.title }}</h1>
100
110
  <p v-if="transformedContent.description" class="cpub-mirror-desc">{{ transformedContent.description }}</p>
101
111
  <div class="cpub-mirror-author">
102
- <strong>{{ transformedContent.author.displayName }}</strong>
103
- <span v-if="authorHandle" class="cpub-mirror-handle">{{ authorHandle }}</span>
112
+ <img v-if="transformedContent.author.avatarUrl" :src="transformedContent.author.avatarUrl" :alt="transformedContent.author.displayName || ''" class="cpub-mirror-author-avatar" />
113
+ <div>
114
+ <strong>{{ transformedContent.author.displayName }}</strong>
115
+ <span v-if="authorHandle" class="cpub-mirror-handle">{{ authorHandle }}</span>
116
+ <span v-if="transformedContent.author.followerCount" class="cpub-mirror-handle">&middot; {{ transformedContent.author.followerCount }} followers</span>
117
+ <p v-if="transformedContent.author.bio" class="cpub-mirror-bio">{{ stripHtml(transformedContent.author.bio) }}</p>
118
+ </div>
104
119
  </div>
105
120
  <div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
106
121
  <div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
@@ -144,7 +159,10 @@ useSeoMeta({
144
159
  .cpub-mirror-cover { width: 100%; max-height: 400px; object-fit: cover; margin-bottom: 20px; }
145
160
  .cpub-mirror-title { font-size: 2rem; font-weight: 800; line-height: 1.2; margin-bottom: 12px; }
146
161
  .cpub-mirror-desc { font-size: 1.0625rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 16px; }
147
- .cpub-mirror-author { font-size: 0.875rem; color: var(--text-dim); margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
162
+ .cpub-mirror-author { font-size: 0.875rem; color: var(--text-dim); margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 12px; }
163
+ .cpub-mirror-author-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: var(--border-width-default) solid var(--border); flex-shrink: 0; }
164
+ .cpub-mirror-bio { font-size: 0.8125rem; color: var(--text-faint); line-height: 1.5; margin-top: 4px; }
165
+ .cpub-mirror-bio :deep(a) { color: var(--accent); }
148
166
  .cpub-mirror-handle { color: var(--text-faint); margin-left: 6px; }
149
167
  .cpub-mirror-body { font-size: 1rem; line-height: 1.75; margin-bottom: 32px; }
150
168
  .cpub-mirror-body :deep(img) { max-width: 100%; }
@@ -0,0 +1,31 @@
1
+ import { incrementFederatedViewCount } from '@commonpub/server';
2
+
3
+ const recentViews = new Map<string, number>();
4
+ const VIEW_COOLDOWN_MS = 5 * 60 * 1000;
5
+
6
+ setInterval(() => {
7
+ const now = Date.now();
8
+ for (const [key, ts] of recentViews) {
9
+ if (now - ts > VIEW_COOLDOWN_MS) recentViews.delete(key);
10
+ }
11
+ }, 120_000);
12
+
13
+ export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
14
+ requireFeature('federation');
15
+ const db = useDB();
16
+ const { id } = parseParams(event, { id: 'uuid' });
17
+
18
+ const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
19
+ || getRequestHeader(event, 'x-real-ip')
20
+ || 'unknown';
21
+ const dedupKey = `fed:${ip}:${id}`;
22
+ const lastView = recentViews.get(dedupKey);
23
+
24
+ if (lastView && Date.now() - lastView < VIEW_COOLDOWN_MS) {
25
+ return { success: true };
26
+ }
27
+
28
+ recentViews.set(dedupKey, Date.now());
29
+ await incrementFederatedViewCount(db, id);
30
+ return { success: true };
31
+ });
@@ -0,0 +1,55 @@
1
+ import { getUnreadMessageCount } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const user = requireAuth(event);
5
+ const userId = user.id;
6
+ const db = useDB();
7
+
8
+ setResponseHeader(event, 'Content-Type', 'text/event-stream');
9
+ setResponseHeader(event, 'Cache-Control', 'no-cache');
10
+ setResponseHeader(event, 'Connection', 'keep-alive');
11
+
12
+ const encoder = new TextEncoder();
13
+ const stream = new ReadableStream({
14
+ async start(controller) {
15
+ let closed = false;
16
+ function cleanup(): void {
17
+ if (closed) return;
18
+ closed = true;
19
+ clearInterval(interval);
20
+ clearInterval(keepalive);
21
+ try { controller.close(); } catch { /* already closed */ }
22
+ }
23
+
24
+ const count = await getUnreadMessageCount(db, userId);
25
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count })}\n\n`));
26
+
27
+ const interval = setInterval(async () => {
28
+ try {
29
+ const currentCount = await getUnreadMessageCount(db, userId);
30
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'count', count: currentCount })}\n\n`));
31
+ } catch {
32
+ cleanup();
33
+ }
34
+ }, 10000);
35
+
36
+ const keepalive = setInterval(() => {
37
+ try {
38
+ controller.enqueue(encoder.encode(': keepalive\n\n'));
39
+ } catch {
40
+ cleanup();
41
+ }
42
+ }, 30000);
43
+
44
+ event.node.req.on('close', cleanup);
45
+ },
46
+ });
47
+
48
+ return new Response(stream, {
49
+ headers: {
50
+ 'Content-Type': 'text/event-stream',
51
+ 'Cache-Control': 'no-cache',
52
+ 'Connection': 'keep-alive',
53
+ },
54
+ });
55
+ });