feat: websockets (comment posting + liking)

This commit is contained in:
Face 2025-05-25 12:06:04 +03:00
parent 251609d7b8
commit 3f137e5c3c
15 changed files with 2200 additions and 5 deletions

View file

@ -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">

View 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>

View 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 };

View file

@ -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);

View file

@ -3,6 +3,7 @@ import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { comment, coin, user, commentLike } from '$lib/server/db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
export async function GET({ params, request }) {
const session = await auth.api.getSession({
@ -117,6 +118,14 @@ export async function POST({ request, params }) {
.where(eq(comment.id, newComment.id))
.limit(1);
await redis.publish(
`comments:${normalizedSymbol}`,
JSON.stringify({
type: 'new_comment',
data: commentWithUser
})
);
return json({ comment: commentWithUser });
} catch (e) {
console.error('Error creating comment:', e);

View file

@ -4,6 +4,7 @@ import { comment, commentLike, coin } from '$lib/server/db/schema';
import { eq, and, sql } from 'drizzle-orm';
import type { RequestHandler } from './$types';
import { auth } from '$lib/auth';
import { redis } from '$lib/server/redis';
export const POST: RequestHandler = async ({ request, params }) => {
const session = await auth.api.getSession({
@ -54,6 +55,24 @@ export const POST: RequestHandler = async ({ request, params }) => {
.where(eq(comment.id, commentId));
});
const [updatedComment] = await db
.select({ likesCount: comment.likesCount })
.from(comment)
.where(eq(comment.id, commentId));
await redis.publish(
`comments:${coinSymbol!.toUpperCase()}`,
JSON.stringify({
type: 'comment_liked',
data: {
commentId: Number(commentId),
likesCount: updatedComment.likesCount,
isLikedByUser: true,
userId
}
})
)
return json({ success: true });
} catch (error) {
console.error('Failed to like comment:', error);
@ -112,6 +131,24 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
.where(eq(comment.id, commentId));
});
const [updatedComment] = await db
.select({ likesCount: comment.likesCount })
.from(comment)
.where(eq(comment.id, commentId));
await redis.publish(
`comments:${coinSymbol.toUpperCase()}`,
JSON.stringify({
type: 'comment_liked',
data: {
commentId: Number(commentId),
likesCount: updatedComment.likesCount,
isLikedByUser: false,
userId
}
})
);
return json({ success: true });
} catch (error) {
console.error('Failed to unlike comment:', error);

View file

@ -164,7 +164,7 @@
{#if leaderboardData.biggestLosers.length === 0}
<div class="py-8 text-center">
<p class="text-muted-foreground">
Everyone's in profit today! 📈 (This won't last...)
No major losses recorded today
</p>
</div>
{:else}