@commonpub/layer 0.3.8 → 0.3.9
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
|
+
}
|
|
@@ -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,
|
package/layouts/default.vue
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
"vue": "^3.4.0",
|
|
45
45
|
"vue-router": "^4.3.0",
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
|
-
"@commonpub/auth": "0.5.0",
|
|
48
47
|
"@commonpub/config": "0.7.0",
|
|
49
|
-
"@commonpub/
|
|
48
|
+
"@commonpub/auth": "0.5.0",
|
|
50
49
|
"@commonpub/docs": "0.5.2",
|
|
51
|
-
"@commonpub/
|
|
50
|
+
"@commonpub/editor": "0.5.0",
|
|
51
|
+
"@commonpub/server": "2.9.0",
|
|
52
|
+
"@commonpub/schema": "0.8.10",
|
|
52
53
|
"@commonpub/protocol": "0.9.4",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/ui": "0.7.1"
|
|
54
|
+
"@commonpub/ui": "0.7.1",
|
|
55
|
+
"@commonpub/learning": "0.5.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {}
|
|
58
58
|
}
|
package/pages/mirror/[id].vue
CHANGED
|
@@ -43,6 +43,11 @@ if (originUrl.value) {
|
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Track view
|
|
47
|
+
onMounted(() => {
|
|
48
|
+
$fetch(`/api/federation/content/${id}/view`, { method: 'POST' }).catch(() => {});
|
|
49
|
+
});
|
|
50
|
+
|
|
46
51
|
useSeoMeta({
|
|
47
52
|
title: transformedContent.value?.title ?? 'Mirrored Content',
|
|
48
53
|
description: transformedContent.value?.description ?? '',
|
|
@@ -99,8 +104,13 @@ useSeoMeta({
|
|
|
99
104
|
<h1 class="cpub-mirror-title">{{ transformedContent.title }}</h1>
|
|
100
105
|
<p v-if="transformedContent.description" class="cpub-mirror-desc">{{ transformedContent.description }}</p>
|
|
101
106
|
<div class="cpub-mirror-author">
|
|
102
|
-
<
|
|
103
|
-
<
|
|
107
|
+
<img v-if="transformedContent.author.avatarUrl" :src="transformedContent.author.avatarUrl" :alt="transformedContent.author.displayName || ''" class="cpub-mirror-author-avatar" />
|
|
108
|
+
<div>
|
|
109
|
+
<strong>{{ transformedContent.author.displayName }}</strong>
|
|
110
|
+
<span v-if="authorHandle" class="cpub-mirror-handle">{{ authorHandle }}</span>
|
|
111
|
+
<span v-if="transformedContent.author.followerCount" class="cpub-mirror-handle">· {{ transformedContent.author.followerCount }} followers</span>
|
|
112
|
+
<p v-if="transformedContent.author.bio" class="cpub-mirror-bio" v-html="transformedContent.author.bio" />
|
|
113
|
+
</div>
|
|
104
114
|
</div>
|
|
105
115
|
<div v-if="typeof transformedContent.content === 'string'" class="cpub-mirror-body prose" v-html="transformedContent.content" />
|
|
106
116
|
<div v-if="transformedContent.tags?.length" class="cpub-mirror-tags">
|
|
@@ -144,7 +154,10 @@ useSeoMeta({
|
|
|
144
154
|
.cpub-mirror-cover { width: 100%; max-height: 400px; object-fit: cover; margin-bottom: 20px; }
|
|
145
155
|
.cpub-mirror-title { font-size: 2rem; font-weight: 800; line-height: 1.2; margin-bottom: 12px; }
|
|
146
156
|
.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); }
|
|
157
|
+
.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; }
|
|
158
|
+
.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; }
|
|
159
|
+
.cpub-mirror-bio { font-size: 0.8125rem; color: var(--text-faint); line-height: 1.5; margin-top: 4px; }
|
|
160
|
+
.cpub-mirror-bio :deep(a) { color: var(--accent); }
|
|
148
161
|
.cpub-mirror-handle { color: var(--text-faint); margin-left: 6px; }
|
|
149
162
|
.cpub-mirror-body { font-size: 1rem; line-height: 1.75; margin-bottom: 32px; }
|
|
150
163
|
.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
|
+
});
|