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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,21 @@
/**
* Derives the WebSocket base URL from env. Mirrors the BE Channels routing,
* which lives behind the same host as the REST API.
* Derives the WebSocket base URL.
*
* Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the
* scheme of `VITE_BACKEND_URL`.
* Priority:
* 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 {
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) {
return `${stripTrailing(explicit)}/ws/chat/${chatId}/`;
}
const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined)
?? "http://localhost:8000";
const wsBase = backend.replace(/^http/, "ws");
// WS endpoints live at the host root (/ws/chat/<id>/), not under /api/.
const hostOnly = wsBase.replace(/\/api\/?$/, "");
return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`;
// Derive scheme from page protocol, host from page host (preserves dev port)
const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${wsScheme}//${window.location.host}/ws/chat/${chatId}/`;
}
function stripTrailing(s: string): string {

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
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 {
useApiSocialChatsCreate,
@@ -156,7 +156,7 @@ export default function CreateChatModal({ open, onClose }: Props) {
/>
{/* 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 */}
<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">
@@ -172,25 +172,28 @@ export default function CreateChatModal({ open, onClose }: Props) {
</button>
</div>
{/* Tab switcher */}
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1">
{(["DM", "GROUP"] as ChatTab[]).map((type) => (
<button
key={type}
type="button"
onClick={() => switchTab(type)}
className={[
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors",
tab === type
? "border-b-2 border-brand-accent text-brand-accent"
: "text-brand-text/60 hover:text-brand-text",
].join(" ")}
>
{type === "DM"
? t("chat.create.tabDM")
: t("chat.create.tabGroup")}
</button>
))}
{/* Type switch */}
<div className="px-5 pt-4 pb-3 border-b border-brand-lines/15">
<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
key={type}
type="button"
onClick={() => switchTab(type)}
className={[
"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
? "text-white shadow-md"
: "text-brand-text/45 hover:text-brand-text/80",
].join(" ")}
style={tab === type ? { backgroundColor: "var(--c-other)", border: "none" } : { border: "none" }}
>
{type === "DM" ? <FiUser size={14} /> : <FiUsers size={14} />}
{type === "DM" ? t("chat.create.tabDM") : t("chat.create.tabGroup")}
</button>
))}
</div>
</div>
{/* Form */}
@@ -259,7 +262,10 @@ export default function CreateChatModal({ open, onClose }: Props) {
{/* Dropdown */}
{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 && (
<div className="flex justify-center py-4">
<Spinner size={18} />

View File

@@ -19,7 +19,8 @@ interface Props {
export default function Message({ message, chat, onReply, onReact }: Props) {
const { t } = useTranslation("social");
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() {
if (!confirm("Smazat zprávu?")) return;
@@ -31,11 +32,11 @@ export default function Message({ message, chat, onReply, onReact }: Props) {
return (
<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={[
"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
? "bg-brand-accent text-brand-bg rounded-br-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 ChatSocketEvent =
| { type: "new_chat_message"; message_id: number; message: string; sender: string }
| { type: "new_reply_chat_message"; message_id: number; message: string; reply_to_id: number; 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_id: number; sender: string; sender_avatar: string | null }
| { type: "edit_chat_message"; message_id: number; content: string; is_edited: boolean }
| { type: "delete_chat_message"; message_id: number }
| { type: "reaction"; message_id: number; emoji: string; user: string; action: "added" | "removed" | "switched" }

View File

@@ -41,20 +41,22 @@ h1, h2, h3 {
line-height: 1.2;
}
button {
border-radius: 0.75rem;
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
font: inherit;
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
color: var(--c-text);
cursor: pointer;
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
@layer base {
button {
border-radius: 0.75rem;
border: 1px solid color-mix(in hsl, var(--c-lines), transparent 60%);
font: inherit;
background-color: color-mix(in hsl, var(--c-background-light), black 15%);
color: var(--c-text);
cursor: pointer;
transition: transform .15s ease, box-shadow .2s ease, border-color .2s ease;
}
button:hover {
border-color: var(--c-other);
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
}
button:active { transform: translateY(1px) }
}
button:hover {
border-color: var(--c-other);
box-shadow: 0 0 0.5rem color-mix(in hsl, var(--c-other), transparent 60%);
}
button:active { transform: translateY(1px) }
/* Reusable helpers */
.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 */
li::marker,
ol li::marker,

View File

@@ -88,7 +88,7 @@ export default function ChatRoomPage() {
appendMessage({
id: event.message_id,
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,
content: event.message,
is_edited: false,

View File

@@ -1,19 +1,21 @@
const BACKEND_ORIGIN = (() => {
try {
return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin;
} catch {
return "http://localhost:8000";
}
})();
/**
* Normalises a media URL so it always resolves through the current origin
* (Vite dev proxy → backend, or nginx in production).
*
* - 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 {
if (!src) return null;
if (src.startsWith("blob:") || src.startsWith("data:")) return src;
try {
// Full URL — strip origin so the request goes through the proxy/nginx
const url = new URL(src);
if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search;
return url.pathname + (url.search || "");
} catch {
// already a relative path — return as-is
// Already a relative path
return src.startsWith("/") ? src : `/${src}`;
}
return src;
}