feat: websockets (comment posting + liking)
This commit is contained in:
parent
251609d7b8
commit
3f137e5c3c
15 changed files with 2200 additions and 5 deletions
|
|
@ -11,6 +11,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { formatTimeAgo, getPublicUrl } from '$lib/utils';
|
||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
import WebSocket, { type WebSocketHandle } from '$lib/components/self/WebSocket.svelte';
|
||||
|
||||
const { coinSymbol } = $props<{ coinSymbol: string }>();
|
||||
import type { Comment } from '$lib/types/comment';
|
||||
|
|
@ -19,6 +20,39 @@
|
|||
let isSubmitting = $state(false);
|
||||
let isLoading = $state(true);
|
||||
let shouldSignIn = $state(false);
|
||||
let wsManager = $state<WebSocketHandle | undefined>();
|
||||
|
||||
function handleWebSocketMessage(message: { type: string; data?: any }) {
|
||||
switch (message.type) {
|
||||
case 'new_comment':
|
||||
// check if comment already exists
|
||||
const commentExists = comments.some((c) => c.id === message.data.id);
|
||||
if (!commentExists) {
|
||||
comments = [message.data, ...comments];
|
||||
}
|
||||
break;
|
||||
case 'comment_liked':
|
||||
const commentIndex = comments.findIndex((c) => c.id === message.data.commentId);
|
||||
if (commentIndex !== -1) {
|
||||
comments[commentIndex] = {
|
||||
...comments[commentIndex],
|
||||
likesCount: message.data.likesCount,
|
||||
isLikedByUser:
|
||||
message.data.userId === Number($USER_DATA?.id)
|
||||
? message.data.isLikedByUser
|
||||
: comments[commentIndex].isLikedByUser
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketOpen() {
|
||||
wsManager?.send({
|
||||
type: 'set_coin',
|
||||
coinSymbol
|
||||
});
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
|
|
@ -52,7 +86,11 @@
|
|||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
comments = [result.comment, ...comments];
|
||||
// check if comment already exists (from ws) before adding
|
||||
const commentExists = comments.some((c) => c.id === result.comment.id);
|
||||
if (!commentExists) {
|
||||
comments = [result.comment, ...comments];
|
||||
}
|
||||
newComment = '';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
|
|
@ -112,6 +150,13 @@
|
|||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
||||
<WebSocket
|
||||
bind:this={wsManager}
|
||||
onMessage={handleWebSocketMessage}
|
||||
onOpen={handleWebSocketOpen}
|
||||
disableReconnect={true}
|
||||
/>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
|
|
|
|||
102
website/src/lib/components/self/WebSocket.svelte
Normal file
102
website/src/lib/components/self/WebSocket.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import { PUBLIC_WEBSOCKET_URL } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export interface WebSocketHandle {
|
||||
send: (data: any) => void;
|
||||
ws: WebSocket | null;
|
||||
}
|
||||
|
||||
type WebSocketMessage = {
|
||||
type: string;
|
||||
data?: any;
|
||||
coinSymbol?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
onMessage,
|
||||
onOpen = undefined,
|
||||
onClose = undefined,
|
||||
disableReconnect = false
|
||||
} = $props<{
|
||||
onMessage: (message: WebSocketMessage) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
disableReconnect?: boolean;
|
||||
}>();
|
||||
|
||||
let ws = $state<WebSocket | null>(null);
|
||||
let reconnectAttempts = $state(0);
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const BASE_DELAY = 1000;
|
||||
|
||||
async function initializeWebSocket() {
|
||||
ws = new WebSocket(PUBLIC_WEBSOCKET_URL);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
reconnectAttempts = 0;
|
||||
onOpen?.();
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage;
|
||||
|
||||
if (message.type === 'ping') {
|
||||
ws?.send(JSON.stringify({ type: 'pong' }));
|
||||
} else {
|
||||
onMessage(message);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket message parse error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', async (event) => {
|
||||
if (onClose) {
|
||||
onClose(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (disableReconnect || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = BASE_DELAY * Math.pow(2, reconnectAttempts);
|
||||
reconnectAttempts++;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
handleReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (event) => {
|
||||
console.error('WebSocket error:', event);
|
||||
});
|
||||
}
|
||||
async function handleReconnect() {
|
||||
try {
|
||||
await initializeWebSocket();
|
||||
} catch (error) {
|
||||
console.error('Reconnect failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initializeWebSocket().catch((error) => {
|
||||
console.error(`Connection failed: ${error.message}`);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ws?.close();
|
||||
ws = null;
|
||||
};
|
||||
});
|
||||
|
||||
export { ws as ws };
|
||||
|
||||
export const send: WebSocketHandle['send'] = (data) => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
19
website/src/lib/server/redis.ts
Normal file
19
website/src/lib/server/redis.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Redis from 'ioredis';
|
||||
import { building } from '$app/environment';
|
||||
import { REDIS_URL } from '$env/static/private';
|
||||
|
||||
if (building) {
|
||||
throw new Error('Redis cannot be used during build');
|
||||
}
|
||||
|
||||
const redis = new Redis(REDIS_URL);
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('Redis connection error:', err);
|
||||
});
|
||||
|
||||
redis.on('connect', () => {
|
||||
console.log('Redis connected successfully');
|
||||
});
|
||||
|
||||
export { redis };
|
||||
|
|
@ -33,7 +33,7 @@ export function getPublicUrl(key: string | null): string | null {
|
|||
}
|
||||
|
||||
export function debounce(func: (...args: any[]) => void, wait: number) {
|
||||
let timeout: number | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
|
|
|
|||
Reference in a new issue