chat ws with pfp is working

This commit is contained in:
David Bruno Vontor
2026-05-28 17:23:04 +02:00
parent f19375254f
commit 8269d044a2
28 changed files with 299 additions and 90 deletions

2
.gitignore vendored
View File

@@ -85,3 +85,5 @@ frontend/.env.*.orig
venv venv
.venv .venv
certs

View File

@@ -0,0 +1,33 @@
# vontor.cz
Czech e-marketplace — Django backend + React frontend.
## Requirements
- Docker Desktop
- Node.js 22+ (frontend dev)
- Python 3.12+ (backend dev)
## Running locally
```sh
docker-compose up
```
Frontend dev server:
```sh
cd frontend && npm install && npm run dev
```
## Docker — corporate / custom CA certificates
If your network uses SSL inspection (corporate proxy, Tailscale, etc.) the Docker build will fail with SSL errors when fetching packages.
Fix: export your Windows Root CA store into the build context once:
```powershell
New-Item -Force -ItemType Directory backend\certs | Out-Null; $s = [Security.Cryptography.X509Certificates.X509Store]::new("Root","LocalMachine"); $s.Open("ReadOnly"); $s.Certificates | % { "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($_.RawData,"InsertLineBreaks") + "`n-----END CERTIFICATE-----" } | Out-File -Encoding ascii backend\certs\windows-ca-bundle.crt; $s.Close()
```
The file is git-ignored. Re-run the command whenever your CA store changes.

View File

@@ -2,11 +2,14 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Trust Windows/corporate root CAs before any network operations
COPY certs/windows-ca-bundle.crt /usr/local/share/ca-certificates/windows-ca-bundle.crt
RUN update-ca-certificates
# Install system dependencies including Node.js for yt-dlp JavaScript runtime # Install system dependencies including Node.js for yt-dlp JavaScript runtime
RUN apt update && apt install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
weasyprint \ weasyprint \
libcairo2 \ libcairo2 \
pango1.0-tools \
libpango-1.0-0 \ libpango-1.0-0 \
libgobject-2.0-0 \ libgobject-2.0-0 \
ffmpeg \ ffmpeg \
@@ -14,9 +17,9 @@ RUN apt update && apt install -y \
curl \ curl \
libmagic1 \ libmagic1 \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt install -y nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& update-ca-certificates \ && update-ca-certificates \
&& apt clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .

View File

@@ -51,7 +51,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
"type": "chat.message", "type": "chat.message",
"message_id": message.id, "message_id": message.id,
"message": message.content, "message": message.content,
"sender_id": user.id,
"sender": user.username, "sender": user.username,
"sender_avatar": user.avatar.url if user.avatar else None,
}) })
elif msg_type == "new_reply_chat_message": elif msg_type == "new_reply_chat_message":
@@ -66,7 +68,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
"message_id": message.id, "message_id": message.id,
"message": message.content, "message": message.content,
"reply_to_id": data.get("reply_to_id"), "reply_to_id": data.get("reply_to_id"),
"sender_id": user.id,
"sender": user.username, "sender": user.username,
"sender_avatar": user.avatar.url if user.avatar else None,
}) })
elif msg_type == "reaction": elif msg_type == "reaction":
@@ -116,7 +120,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
"type": "new_chat_message", "type": "new_chat_message",
"message_id": event["message_id"], "message_id": event["message_id"],
"message": event["message"], "message": event["message"],
"sender_id": event["sender_id"],
"sender": event["sender"], "sender": event["sender"],
"sender_avatar": event["sender_avatar"],
})) }))
async def reply_chat_message(self, event): async def reply_chat_message(self, event):
@@ -125,7 +131,9 @@ class ChatConsumer(AsyncWebsocketConsumer):
"message_id": event["message_id"], "message_id": event["message_id"],
"message": event["message"], "message": event["message"],
"reply_to_id": event["reply_to_id"], "reply_to_id": event["reply_to_id"],
"sender_id": event["sender_id"],
"sender": event["sender"], "sender": event["sender"],
"sender_avatar": event["sender_avatar"],
})) }))
async def edit_message(self, event): async def edit_message(self, event):
@@ -178,7 +186,8 @@ class ChatConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def _is_chat_member(chat_id, user): def _is_chat_member(chat_id, user):
return Chat.objects.filter(pk=chat_id, members=user).exists() from django.db.models import Q
return Chat.objects.filter(Q(pk=chat_id), Q(members=user) | Q(owner=user)).exists()
@database_sync_to_async @database_sync_to_async

View File

@@ -1,8 +1,24 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction
class MessageSenderSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
def get_avatar(self, obj):
from django.conf import settings
if obj.avatar:
return settings.MEDIA_URL + obj.avatar.name
return None
class Meta:
model = get_user_model()
fields = ['id', 'username', 'avatar']
read_only_fields = ['id', 'username']
class MessageFileSerializer(serializers.ModelSerializer): class MessageFileSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = MessageFile model = MessageFile
@@ -25,6 +41,7 @@ class MessageHistorySerializer(serializers.ModelSerializer):
class MessageSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer):
sender = MessageSenderSerializer(read_only=True)
media_files = MessageFileSerializer(many=True, read_only=True) media_files = MessageFileSerializer(many=True, read_only=True)
reactions = MessageReactionSerializer(many=True, read_only=True) reactions = MessageReactionSerializer(many=True, read_only=True)

View File

@@ -1,6 +1,7 @@
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import Q
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -49,7 +50,7 @@ class ChatViewSet(viewsets.ModelViewSet):
user = self.request.user user = self.request.user
if user.is_superuser: if user.is_superuser:
return Chat.objects.all() return Chat.objects.all()
return Chat.objects.filter(members=user) return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct()
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)

View File

@@ -16,15 +16,17 @@ from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from channels.routing import ProtocolTypeRouter, URLRouter from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack from social.chat.routing import websocket_urlpatterns as social_ws
import social.chat.routing
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
from vontor_cz.middleware import JWTAuthMiddleware
websocket_urlpatterns = downloader_ws + social_ws
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": django_asgi_app, "http": django_asgi_app,
"websocket": AuthMiddlewareStack( "websocket": JWTAuthMiddleware(
URLRouter( URLRouter(
downloader_ws + social.chat.routing.websocket_urlpatterns websocket_urlpatterns
) )
), ),
}) })

View File

@@ -0,0 +1,83 @@
import logging
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import AccessToken
logger = logging.getLogger(__name__)
# ---------- WEBSOCKET JWT AUTH VALIDATION -----------
@database_sync_to_async
def get_user_from_token(token_string):
"""
Validate JWT token and return user
"""
try:
# Validate the token
access_token = AccessToken(token_string)
# Get user ID from token
user_id = access_token.get('user_id')
if not user_id:
return AnonymousUser()
# Import here to avoid circular imports
from account.models import CustomUser
# Get user from database
try:
user = CustomUser.objects.get(id=user_id)
return user
except CustomUser.DoesNotExist:
return AnonymousUser()
except (TokenError, InvalidToken, Exception) as e:
logger.warning(f"JWT validation failed in websocket: {e}")
return AnonymousUser()
class JWTAuthMiddleware(BaseMiddleware):
"""
Custom middleware to authenticate WebSocket connections using JWT from cookies.
Replaces AuthMiddlewareStack / CSRF validation for WebSocket routes.
"""
async def __call__(self, scope, receive, send):
# Get headers from scope
headers = dict(scope.get('headers', []))
# Extract cookies from headers
cookie_header = headers.get(b'cookie', b'').decode('utf-8')
logger.info(f"[WS] cookie header present: {bool(cookie_header)} | keys: {[c.split('=')[0].strip() for c in cookie_header.split('; ') if '=' in c]}")
# Parse cookies
cookies = {}
if cookie_header:
for cookie in cookie_header.split('; '):
if '=' in cookie:
key, value = cookie.split('=', 1)
cookies[key.strip()] = value
# Get access_token from cookies
token = cookies.get('access_token')
logger.info(f"[WS] token found: {bool(token)}")
# Authenticate user
if token:
scope['user'] = await get_user_from_token(token)
else:
scope['user'] = AnonymousUser()
logger.info(f"[WS] authenticated as: {scope['user']} | is_authenticated: {getattr(scope['user'], 'is_authenticated', False)}")
return await super().__call__(scope, receive, send)
# ---------- END | WEBSOCKET JWT AUTH VALIDATION -----------

View File

@@ -306,12 +306,8 @@ else:
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
# In DEBUG keep Session + JWT + your cookie class for convenience
'rest_framework.authentication.SessionAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
'account.tokens.CookieJWTAuthentication', 'account.tokens.CookieJWTAuthentication',
) if DEBUG else (
'account.tokens.CookieJWTAuthentication',
), ),
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny', 'rest_framework.permissions.AllowAny',
@@ -427,7 +423,6 @@ MIDDLEWARE = [
'silk.middleware.SilkyMiddleware', 'silk.middleware.SilkyMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',

View File

@@ -91,6 +91,8 @@ services:
nginx: #web server, reverse proxy, serves static files nginx: #web server, reverse proxy, serves static files
container_name: nginx-vontor-cz container_name: nginx-vontor-cz
profiles:
- production
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.prod dockerfile: Dockerfile.prod

View File

@@ -1,5 +1,6 @@
# Base URL of the Django backend (must include /api/ if your axios baseURL expects it). # Base URL of the Django backend (must include /api/ if your axios baseURL expects it).
VITE_BACKEND_URL="http://localhost:8000/api/" VITE_BACKEND_URL="http://localhost:8000/api/"
VITE_BACKEND_WS_URL="ws://localhost:8000/"
# Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL # Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL
# (the `/api` suffix is stripped automatically; only the host is used). # (the `/api` suffix is stripped automatically; only the host is used).

View File

@@ -43,6 +43,21 @@ http {
alias /app/media/; alias /app/media/;
} }
# -------------------------
# WebSocket proxy
# -------------------------
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Same-origin proxy for API -> avoids CORS and allows cookies # Same-origin proxy for API -> avoids CORS and allows cookies
location /api { location /api {
return 301 /api/; return 301 /api/;

View File

@@ -67,6 +67,7 @@ export * from "./message";
export * from "./messageFile"; export * from "./messageFile";
export * from "./messageReaction"; export * from "./messageReaction";
export * from "./messageSend"; export * from "./messageSend";
export * from "./messageSender";
export * from "./orderCarrier"; export * from "./orderCarrier";
export * from "./orderCreate"; export * from "./orderCreate";
export * from "./orderItemCreate"; export * from "./orderItemCreate";

View File

@@ -5,12 +5,12 @@
*/ */
import type { MessageFile } from "./messageFile"; import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction"; import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
export interface Message { export interface Message {
readonly id: number; readonly id: number;
readonly chat: number; readonly chat: number;
/** @nullable */ readonly sender: MessageSender;
readonly sender: number | null;
/** @nullable */ /** @nullable */
readonly reply_to: number | null; readonly reply_to: number | null;
content?: string; content?: string;

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface MessageSender {
readonly id: number;
/** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */
readonly username: string;
readonly avatar: string;
}

View File

@@ -5,12 +5,12 @@
*/ */
import type { MessageFile } from "./messageFile"; import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction"; import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
export interface PatchedMessage { export interface PatchedMessage {
readonly id?: number; readonly id?: number;
readonly chat?: number; readonly chat?: number;
/** @nullable */ readonly sender?: MessageSender;
readonly sender?: number | null;
/** @nullable */ /** @nullable */
readonly reply_to?: number | null; readonly reply_to?: number | null;
content?: string; content?: string;

View File

@@ -49,6 +49,7 @@ export * from "./message";
export * from "./messageFile"; export * from "./messageFile";
export * from "./messageReaction"; export * from "./messageReaction";
export * from "./messageSend"; export * from "./messageSend";
export * from "./messageSender";
export * from "./orderCarrier"; export * from "./orderCarrier";
export * from "./orderCreate"; export * from "./orderCreate";
export * from "./orderItemCreate"; export * from "./orderItemCreate";

View File

@@ -5,12 +5,12 @@
*/ */
import type { MessageFile } from "./messageFile"; import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction"; import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
export interface Message { export interface Message {
readonly id: number; readonly id: number;
readonly chat: number; readonly chat: number;
/** @nullable */ readonly sender: MessageSender;
readonly sender: number | null;
/** @nullable */ /** @nullable */
readonly reply_to: number | null; readonly reply_to: number | null;
content?: string; content?: string;

View File

@@ -0,0 +1,12 @@
/**
* Generated by orval v8.8.0 🍺
* Do not edit manually.
* OpenAPI spec version: 0.0.0
*/
export interface MessageSender {
readonly id: number;
/** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */
readonly username: string;
readonly avatar: string;
}

View File

@@ -5,12 +5,12 @@
*/ */
import type { MessageFile } from "./messageFile"; import type { MessageFile } from "./messageFile";
import type { MessageReaction } from "./messageReaction"; import type { MessageReaction } from "./messageReaction";
import type { MessageSender } from "./messageSender";
export interface PatchedMessage { export interface PatchedMessage {
readonly id?: number; readonly id?: number;
readonly chat?: number; readonly chat?: number;
/** @nullable */ readonly sender?: MessageSender;
readonly sender?: number | null;
/** @nullable */ /** @nullable */
readonly reply_to?: number | null; readonly reply_to?: number | null;
content?: string; content?: string;

View File

@@ -1,21 +1,21 @@
/** /**
* Derives the WebSocket base URL from env. Mirrors the BE Channels routing, * Derives the WebSocket base URL.
* which lives behind the same host as the REST API.
* *
* Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the * Priority:
* scheme of `VITE_BACKEND_URL`. * 1. `VITE_WS_URL` env var — explicit override (e.g. wss://example.com)
* 2. Current page origin — uses window.location so the request goes through
* whatever proxy is in front (Vite dev proxy or nginx in production).
* This avoids hardcoding the backend port and bypassing the proxy.
*/ */
export function getChatSocketUrl(chatId: number | string): string { export function getChatSocketUrl(chatId: number | string): string {
const explicit = import.meta.env.VITE_WS_URL as string | undefined; const explicit = import.meta.env.VITE_BACKEND_WS_URL as string | undefined;
if (explicit) { if (explicit) {
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`; return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
} }
const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined)
?? "http://localhost:8000"; // Derive scheme from page protocol, host from page host (preserves dev port)
const wsBase = backend.replace(/^http/, "ws"); const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:";
// WS endpoints live at the host root (/ws/chat/<id>/), not under /api/. return `${wsScheme}//${window.location.host}/ws/chat/${chatId}/`;
const hostOnly = wsBase.replace(/\/api\/?$/, "");
return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`;
} }
function stripTrailing(s: string): string { function stripTrailing(s: string): string {

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FiX, FiSearch, FiCheck } from "react-icons/fi"; import { FiX, FiSearch, FiCheck, FiUser, FiUsers } from "react-icons/fi";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
useApiSocialChatsCreate, useApiSocialChatsCreate,
@@ -156,7 +156,7 @@ export default function CreateChatModal({ open, onClose }: Props) {
/> />
{/* Dialog */} {/* Dialog */}
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-bgLight shadow-2xl"> <div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-gradient shadow-2xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4"> <div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4">
<h2 className="text-base font-semibold text-brand-text"> <h2 className="text-base font-semibold text-brand-text">
@@ -172,26 +172,29 @@ export default function CreateChatModal({ open, onClose }: Props) {
</button> </button>
</div> </div>
{/* Tab switcher */} {/* Type switch */}
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1"> <div className="px-5 pt-4 pb-3 border-b border-brand-lines/15">
{(["DM", "GROUP"] as ChatTab[]).map((type) => ( <div className="flex rounded-full bg-brand-bg/80 p-0.5 border border-brand-lines/20">
{(["DM", "GROUP"] as ChatTab[]).map((type, i) => (
<button <button
key={type} key={type}
type="button" type="button"
onClick={() => switchTab(type)} onClick={() => switchTab(type)}
className={[ className={[
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors", "flex flex-1 items-center justify-center gap-2 px-4 py-2 text-sm font-semibold transition-all duration-200",
i === 0 ? "rounded-l-full" : "rounded-r-full",
tab === type tab === type
? "border-b-2 border-brand-accent text-brand-accent" ? "text-white shadow-md"
: "text-brand-text/60 hover:text-brand-text", : "text-brand-text/45 hover:text-brand-text/80",
].join(" ")} ].join(" ")}
style={tab === type ? { backgroundColor: "var(--c-other)", border: "none" } : { border: "none" }}
> >
{type === "DM" {type === "DM" ? <FiUser size={14} /> : <FiUsers size={14} />}
? t("chat.create.tabDM") {type === "DM" ? t("chat.create.tabDM") : t("chat.create.tabGroup")}
: t("chat.create.tabGroup")}
</button> </button>
))} ))}
</div> </div>
</div>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="px-5 py-5 space-y-4"> <form onSubmit={handleSubmit} className="px-5 py-5 space-y-4">
@@ -259,7 +262,10 @@ export default function CreateChatModal({ open, onClose }: Props) {
{/* Dropdown */} {/* Dropdown */}
{showDropdown && debouncedQuery.length >= 2 && ( {showDropdown && debouncedQuery.length >= 2 && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 bg-brand-bg shadow-xl"> <div
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 shadow-xl"
style={{ backgroundColor: "var(--c-background)" }}
>
{usersLoading && ( {usersLoading && (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Spinner size={18} /> <Spinner size={18} />

View File

@@ -19,7 +19,8 @@ interface Props {
export default function Message({ message, chat, onReply, onReact }: Props) { export default function Message({ message, chat, onReply, onReact }: Props) {
const { t } = useTranslation("social"); const { t } = useTranslation("social");
const { user } = useAuth(); const { user } = useAuth();
const isOwn = user?.id != null && message.sender === user.id; const sender = message.sender as { id: number; username: string; avatar: string | null } | null;
const isOwn = user?.id != null && sender?.id === user.id;
async function handleDelete() { async function handleDelete() {
if (!confirm("Smazat zprávu?")) return; if (!confirm("Smazat zprávu?")) return;
@@ -31,11 +32,11 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
return ( return (
<div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}> <div className={`group flex gap-2 px-4 py-1.5 ${isOwn ? "flex-row-reverse" : ""}`}>
<Avatar name={`user ${message.sender}`} size={28} /> <Avatar name={sender?.username} src={sender?.avatar} size={28} />
<div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}> <div className={`flex max-w-[70%] flex-col ${isOwn ? "items-end" : "items-start"}`}>
<div <div
className={[ className={[
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap", "rounded-2xl px-3 py-2 text-sm wrap-break-word whitespace-pre-wrap",
isOwn isOwn
? "bg-brand-accent text-brand-bg rounded-br-sm" ? "bg-brand-accent text-brand-bg rounded-br-sm"
: "bg-brand-bgLight/70 text-brand-text rounded-bl-sm", : "bg-brand-bgLight/70 text-brand-text rounded-bl-sm",

View File

@@ -4,8 +4,8 @@ import { getChatSocketUrl } from "@/api/social/ws";
export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error"; export type ChatSocketStatus = "idle" | "connecting" | "open" | "closed" | "error";
export type ChatSocketEvent = export type ChatSocketEvent =
| { type: "new_chat_message"; message_id: number; message: string; sender: string } | { type: "new_chat_message"; message_id: number; message: string; sender_id: number; sender: string; sender_avatar: string | null }
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender: string } | { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; sender_id: number; sender: string; sender_avatar: string | null }
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean } | { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
| { type: "delete_chat_message"; message_id: number } | { type: "delete_chat_message"; message_id: number }
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" } | { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }

View File

@@ -41,6 +41,7 @@ h1, h2, h3 {
line-height: 1.2; line-height: 1.2;
} }
@layer base {
button { button {
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%); border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
@@ -55,6 +56,7 @@ button:hover {
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%); box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
} }
button:active { transform: translateY(1px) } button:active { transform: translateY(1px) }
}
/* Reusable helpers */ /* Reusable helpers */
.glass { .glass {
@@ -157,6 +159,14 @@ button:active { transform: translateY(1px) }
} }
} }
/* Reusable full-page gradient — same background as the login/register pages */
.bg-brand-gradient {
background:
radial-gradient(1200px 800px at 10% 10%, var(--c-background-light), transparent 60%),
radial-gradient(1000px 700px at 90% 20%, color-mix(in hsl, var(--c-lines), transparent 85%), transparent 60%),
linear-gradient(180deg, var(--c-background) 0%, color-mix(in hsl, var(--c-background), black 15%) 100%);
}
/* Use project palette for list markers (bullets) and summary markers */ /* Use project palette for list markers (bullets) and summary markers */
li::marker, li::marker,
ol li::marker, ol li::marker,

View File

@@ -88,7 +88,7 @@ export default function ChatRoomPage() {
appendMessage({ appendMessage({
id: event.message_id, id: event.message_id,
chat: chatId, chat: chatId,
sender: null, sender: { id: event.sender_id, username: event.sender, avatar: event.sender_avatar } as never,
reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null, reply_to: event.type === "new_reply_chat_message" ? event.reply_to_id : null,
content: event.message, content: event.message,
is_edited: false, is_edited: false,

View File

@@ -1,19 +1,21 @@
const BACKEND_ORIGIN = (() => { /**
try { * Normalises a media URL so it always resolves through the current origin
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin; * (Vite dev proxy → backend, or nginx in production).
} catch { *
return "http://localhost:8000"; * - Full URLs (http/https): strip the origin, keep only the path.
} * - Relative paths without a leading slash: add one.
})(); * - blob: / data: URLs: returned unchanged.
* - null / undefined / empty: returns null.
*/
export function mediaUrl(src: string | null | undefined): string | null { export function mediaUrl(src: string | null | undefined): string | null {
if (!src) return null; if (!src) return null;
if (src.startsWith("blob:") || src.startsWith("data:")) return src; if (src.startsWith("blob:") || src.startsWith("data:")) return src;
try { try {
// Full URL — strip origin so the request goes through the proxy/nginx
const url = new URL(src); const url = new URL(src);
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search; return url.pathname + (url.search || "");
} catch { } catch {
// already a relative path — return as-is // Already a relative path
return src.startsWith("/") ? src : `/${src}`;
} }
return src;
} }

View File

@@ -42,11 +42,12 @@ export default defineConfig(({ mode }) => {
target: backendUrl, target: backendUrl,
changeOrigin: true, changeOrigin: true,
}, },
'/ws': { /*'/ws': {
target: backendUrl, target: 'ws://localhost:8000/',
ws: true, ws: true,
changeOrigin: true, changeOrigin: true,
}, rewriteWsOrigin: true,
},*/
}, },
}, },
}; };