@commonpub/layer 0.3.21 → 0.3.22

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,53 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ text: string;
4
+ }>();
5
+
6
+ interface TextSegment {
7
+ type: 'text' | 'mention';
8
+ value: string;
9
+ }
10
+
11
+ const MENTION_RE = /(?:^|(?<=[\s(]))@([a-zA-Z0-9_-]{1,39})(?=[\s,.!?;:)\]|]|$)/g;
12
+
13
+ const segments = computed((): TextSegment[] => {
14
+ const result: TextSegment[] = [];
15
+ let lastIndex = 0;
16
+
17
+ for (const match of props.text.matchAll(MENTION_RE)) {
18
+ const start = match.index!;
19
+ if (start > lastIndex) {
20
+ result.push({ type: 'text', value: props.text.slice(lastIndex, start) });
21
+ }
22
+ result.push({ type: 'mention', value: match[1]! });
23
+ lastIndex = start + match[0].length;
24
+ }
25
+
26
+ if (lastIndex < props.text.length) {
27
+ result.push({ type: 'text', value: props.text.slice(lastIndex) });
28
+ }
29
+
30
+ return result.length > 0 ? result : [{ type: 'text', value: props.text }];
31
+ });
32
+ </script>
33
+
34
+ <template>
35
+ <span class="cpub-mention-text">
36
+ <template v-for="(seg, idx) in segments" :key="idx">
37
+ <NuxtLink v-if="seg.type === 'mention'" :to="`/u/${seg.value}`" class="cpub-mention">@{{ seg.value }}</NuxtLink>
38
+ <template v-else>{{ seg.value }}</template>
39
+ </template>
40
+ </span>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .cpub-mention {
45
+ color: var(--accent);
46
+ text-decoration: none;
47
+ font-weight: 500;
48
+ }
49
+
50
+ .cpub-mention:hover {
51
+ text-decoration: underline;
52
+ }
53
+ </style>
@@ -1,57 +1,10 @@
1
- /** Composable for real-time unread message count via SSE */
1
+ /** Composable for real-time unread message count. Delegates to unified SSE stream. */
2
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
- }
3
+ const { messageCount, connected, connect, disconnect } = useRealtimeCounts();
51
4
 
52
5
  return {
53
- count: readonly(count),
54
- connected: readonly(connected),
6
+ count: messageCount,
7
+ connected,
55
8
  connect,
56
9
  disconnect,
57
10
  };
@@ -1,73 +1,13 @@
1
- /** Composable for real-time notification count via SSE */
1
+ /** Composable for real-time notification count. Delegates to unified SSE stream. */
2
2
  export function useNotifications() {
3
- const count = useState<number>('notification-count', () => 0);
4
- const connected = useState<boolean>('notification-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/notifications/stream');
15
- connected.value = true;
16
-
17
- eventSource.onopen = () => {
18
- retryDelay = 5000; // Reset backoff on successful connection
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 {
28
- // Ignore malformed messages
29
- }
30
- };
31
-
32
- eventSource.onerror = () => {
33
- connected.value = false;
34
- // EventSource readyState 2 = CLOSED (server rejected, e.g. 401)
35
- const wasClosed = eventSource?.readyState === 2;
36
- eventSource?.close();
37
- eventSource = null;
38
- // If the connection was fully closed (auth error, 401, etc.), don't retry
39
- if (wasClosed) return;
40
- // Exponential backoff: 5s → 10s → 20s → 40s → 60s cap
41
- retryTimer = setTimeout(connect, retryDelay);
42
- retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
43
- };
44
- }
45
-
46
- function disconnect(): void {
47
- if (retryTimer) {
48
- clearTimeout(retryTimer);
49
- retryTimer = null;
50
- }
51
- eventSource?.close();
52
- eventSource = null;
53
- connected.value = false;
54
- retryDelay = 5000;
55
- }
56
-
57
- function decrement(): void {
58
- if (count.value > 0) count.value--;
59
- }
60
-
61
- function reset(): void {
62
- count.value = 0;
63
- }
3
+ const { notificationCount, connected, connect, disconnect, decrementNotifications, resetNotifications } = useRealtimeCounts();
64
4
 
65
5
  return {
66
- count: readonly(count),
67
- connected: readonly(connected),
6
+ count: notificationCount,
7
+ connected,
68
8
  connect,
69
9
  disconnect,
70
- decrement,
71
- reset,
10
+ decrement: decrementNotifications,
11
+ reset: resetNotifications,
72
12
  };
73
13
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unified composable for real-time notification and message counts via a single SSE stream.
3
+ * Replaces the separate useNotifications() and useMessages() EventSource connections.
4
+ */
5
+ export function useRealtimeCounts() {
6
+ const notificationCount = useState<number>('notification-count', () => 0);
7
+ const messageCount = useState<number>('message-count', () => 0);
8
+ const connected = useState<boolean>('realtime-connected', () => false);
9
+
10
+ let eventSource: EventSource | null = null;
11
+ let retryDelay = 5000;
12
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
13
+ const MAX_RETRY_DELAY = 60_000;
14
+
15
+ function connect(): void {
16
+ if (import.meta.server || eventSource) return;
17
+
18
+ eventSource = new EventSource('/api/realtime/stream');
19
+ connected.value = true;
20
+
21
+ eventSource.onopen = () => {
22
+ retryDelay = 5000;
23
+ };
24
+
25
+ eventSource.onmessage = (event) => {
26
+ try {
27
+ const data = JSON.parse(event.data) as { type: string; notifications?: number; messages?: number };
28
+ if (data.type === 'counts') {
29
+ if (typeof data.notifications === 'number') notificationCount.value = data.notifications;
30
+ if (typeof data.messages === 'number') messageCount.value = data.messages;
31
+ }
32
+ } catch { /* ignore */ }
33
+ };
34
+
35
+ eventSource.onerror = () => {
36
+ connected.value = false;
37
+ const wasClosed = eventSource?.readyState === 2;
38
+ eventSource?.close();
39
+ eventSource = null;
40
+ if (wasClosed) return;
41
+ retryTimer = setTimeout(connect, retryDelay);
42
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
43
+ };
44
+ }
45
+
46
+ function disconnect(): void {
47
+ if (retryTimer) {
48
+ clearTimeout(retryTimer);
49
+ retryTimer = null;
50
+ }
51
+ eventSource?.close();
52
+ eventSource = null;
53
+ connected.value = false;
54
+ retryDelay = 5000;
55
+ }
56
+
57
+ function decrementNotifications(): void {
58
+ if (notificationCount.value > 0) notificationCount.value--;
59
+ }
60
+
61
+ function resetNotifications(): void {
62
+ notificationCount.value = 0;
63
+ }
64
+
65
+ return {
66
+ notificationCount: readonly(notificationCount),
67
+ messageCount: readonly(messageCount),
68
+ connected: readonly(connected),
69
+ connect,
70
+ disconnect,
71
+ decrementNotifications,
72
+ resetNotifications,
73
+ };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -50,8 +50,8 @@
50
50
  "@commonpub/editor": "0.5.0",
51
51
  "@commonpub/learning": "0.5.0",
52
52
  "@commonpub/protocol": "0.9.4",
53
- "@commonpub/server": "2.13.1",
54
53
  "@commonpub/schema": "0.8.12",
54
+ "@commonpub/server": "2.14.0",
55
55
  "@commonpub/ui": "0.7.1"
56
56
  },
57
57
  "scripts": {}
@@ -1,6 +1,8 @@
1
1
  import { getUnreadMessageCount } from '@commonpub/server';
2
2
 
3
+ /** @deprecated Use /api/realtime/stream instead */
3
4
  export default defineEventHandler(async (event) => {
5
+ console.warn('[deprecated] /api/messages/stream — use /api/realtime/stream instead');
4
6
  const user = requireAuth(event);
5
7
  const userId = user.id;
6
8
  const db = useDB();
@@ -1,6 +1,8 @@
1
1
  import { getUnreadCount } from '@commonpub/server';
2
2
 
3
+ /** @deprecated Use /api/realtime/stream instead */
3
4
  export default defineEventHandler(async (event) => {
5
+ console.warn('[deprecated] /api/notifications/stream — use /api/realtime/stream instead');
4
6
  const user = requireAuth(event);
5
7
  const userId = user.id;
6
8
  const db = useDB();
@@ -0,0 +1,65 @@
1
+ import { getUnreadCount, getUnreadMessageCount } from '@commonpub/server';
2
+
3
+ /**
4
+ * Unified SSE stream for notification and message counts.
5
+ * Replaces the separate /api/notifications/stream and /api/messages/stream endpoints.
6
+ * Sends both counts in a single event, halving DB polls and open connections.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ const user = requireAuth(event);
10
+ const userId = user.id;
11
+ const db = useDB();
12
+
13
+ const encoder = new TextEncoder();
14
+ const stream = new ReadableStream({
15
+ async start(controller) {
16
+ let closed = false;
17
+ function cleanup(): void {
18
+ if (closed) return;
19
+ closed = true;
20
+ clearInterval(interval);
21
+ clearInterval(keepalive);
22
+ try { controller.close(); } catch { /* already closed */ }
23
+ }
24
+
25
+ async function sendCounts(): Promise<void> {
26
+ const [notifications, messages] = await Promise.all([
27
+ getUnreadCount(db, userId),
28
+ getUnreadMessageCount(db, userId),
29
+ ]);
30
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
31
+ }
32
+
33
+ // Send initial counts
34
+ await sendCounts();
35
+
36
+ // Poll every 10 seconds
37
+ const interval = setInterval(async () => {
38
+ try {
39
+ await sendCounts();
40
+ } catch {
41
+ cleanup();
42
+ }
43
+ }, 10000);
44
+
45
+ // Keepalive every 30 seconds
46
+ const keepalive = setInterval(() => {
47
+ try {
48
+ controller.enqueue(encoder.encode(': keepalive\n\n'));
49
+ } catch {
50
+ cleanup();
51
+ }
52
+ }, 30000);
53
+
54
+ event.node.req.on('close', cleanup);
55
+ },
56
+ });
57
+
58
+ return new Response(stream, {
59
+ headers: {
60
+ 'Content-Type': 'text/event-stream',
61
+ 'Cache-Control': 'no-cache',
62
+ 'Connection': 'keep-alive',
63
+ },
64
+ });
65
+ });