feat: improve chat realtime and media composer UX
All checks were successful
CI / test (push) Successful in 27s

- add media preview and upload confirmation for image/video

- add upload progress tracking for presigned uploads

- keep voice recording/upload flow with better UI states

- include related realtime/chat updates currently in working tree
This commit is contained in:
2026-03-07 22:46:04 +03:00
parent 9ef9366aca
commit f95a0e9727
10 changed files with 279 additions and 83 deletions

View File

@@ -1,4 +1,5 @@
from sqlalchemy import Select, select from sqlalchemy import Select, select
from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
@@ -60,3 +61,21 @@ async def list_user_chat_ids(db: AsyncSession, *, user_id: int) -> list[int]:
select(ChatMember.chat_id).where(ChatMember.user_id == user_id).order_by(ChatMember.chat_id.asc()) select(ChatMember.chat_id).where(ChatMember.user_id == user_id).order_by(ChatMember.chat_id.asc())
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> Chat | None:
cm_a = aliased(ChatMember)
cm_b = aliased(ChatMember)
stmt = (
select(Chat)
.join(cm_a, cm_a.chat_id == Chat.id)
.join(cm_b, cm_b.chat_id == Chat.id)
.where(
Chat.type == ChatType.PRIVATE,
cm_a.user_id == user_a_id,
cm_b.user_id == user_b_id,
)
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -16,6 +16,14 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Private chat requires exactly one target user.", detail="Private chat requires exactly one target user.",
) )
if payload.type == ChatType.PRIVATE:
existing_chat = await repository.find_private_chat_between_users(
db,
user_a_id=creator_id,
user_b_id=member_ids[0],
)
if existing_chat:
return existing_chat
if payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.title: if payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.title:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View File

@@ -5,6 +5,7 @@ from app.auth.service import get_current_user
from app.database.session import get_db from app.database.session import get_db
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageUpdateRequest from app.messages.schemas import MessageCreateRequest, MessageRead, MessageUpdateRequest
from app.messages.service import create_chat_message, delete_message, get_messages, update_message from app.messages.service import create_chat_message, delete_message, get_messages, update_message
from app.realtime.service import realtime_gateway
from app.users.models import User from app.users.models import User
router = APIRouter(prefix="/messages", tags=["messages"]) router = APIRouter(prefix="/messages", tags=["messages"])
@@ -16,7 +17,9 @@ async def create_message(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> MessageRead: ) -> MessageRead:
return await create_chat_message(db, sender_id=current_user.id, payload=payload) message = await create_chat_message(db, sender_id=current_user.id, payload=payload)
await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id)
return message
@router.get("/{chat_id}", response_model=list[MessageRead]) @router.get("/{chat_id}", response_model=list[MessageRead])

View File

@@ -83,17 +83,7 @@ class RealtimeGateway:
sender_id=user_id, sender_id=user_id,
payload=MessageCreateRequest(chat_id=payload.chat_id, type=payload.type, text=payload.text), payload=MessageCreateRequest(chat_id=payload.chat_id, type=payload.type, text=payload.text),
) )
message_data = MessageRead.model_validate(message).model_dump(mode="json") await self.publish_message_created(message=message, sender_id=user_id, temp_id=payload.temp_id)
await self._publish_chat_event(
payload.chat_id,
event="receive_message",
payload={
"chat_id": payload.chat_id,
"message": message_data,
"temp_id": payload.temp_id,
"sender_id": user_id,
},
)
async def handle_typing_event(self, db: AsyncSession, user_id: int, payload: ChatEventPayload, event: str) -> None: async def handle_typing_event(self, db: AsyncSession, user_id: int, payload: ChatEventPayload, event: str) -> None:
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id) await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=user_id)
@@ -149,6 +139,19 @@ class RealtimeGateway:
return return
await self._handle_redis_event(f"chat:{chat_id}", event_payload) await self._handle_redis_event(f"chat:{chat_id}", event_payload)
async def publish_message_created(self, *, message, sender_id: int, temp_id: str | None = None) -> None:
message_data = MessageRead.model_validate(message).model_dump(mode="json")
await self._publish_chat_event(
message.chat_id,
event="receive_message",
payload={
"chat_id": message.chat_id,
"message": message_data,
"temp_id": temp_id,
"sender_id": sender_id,
},
)
async def _send_user_event(self, user_id: int, event: OutgoingRealtimeEvent) -> None: async def _send_user_event(self, user_id: int, event: OutgoingRealtimeEvent) -> None:
user_connections = self._connections.get(user_id, {}) user_connections = self._connections.get(user_id, {})
if not user_connections: if not user_connections:

View File

@@ -1,5 +1,6 @@
import { http } from "./http"; import { http } from "./http";
import type { Chat, ChatType, Message, MessageType } from "../chat/types"; import type { Chat, ChatType, Message, MessageType } from "../chat/types";
import axios from "axios";
export async function getChats(): Promise<Chat[]> { export async function getChats(): Promise<Chat[]> {
const { data } = await http.get<Chat[]>("/chats"); const { data } = await http.get<Chat[]>("/chats");
@@ -51,6 +52,23 @@ export async function requestUploadUrl(file: File): Promise<UploadUrlResponse> {
return data; return data;
} }
export async function uploadToPresignedUrl(
uploadUrl: string,
requiredHeaders: Record<string, string>,
file: File,
onProgress?: (percent: number) => void
): Promise<void> {
await axios.put(uploadUrl, file, {
headers: requiredHeaders,
onUploadProgress: (progressEvent) => {
if (!onProgress || !progressEvent.total) {
return;
}
onProgress(Math.round((progressEvent.loaded * 100) / progressEvent.total));
}
});
}
export async function attachFile(messageId: number, fileUrl: string, fileType: string, fileSize: number): Promise<void> { export async function attachFile(messageId: number, fileUrl: string, fileType: string, fileSize: number): Promise<void> {
await http.post("/media/attachments", { await http.post("/media/attachments", {
message_id: messageId, message_id: messageId,

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { attachFile, requestUploadUrl, sendMessage } from "../api/chats"; import { attachFile, requestUploadUrl, sendMessage, uploadToPresignedUrl } from "../api/chats";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { buildWsUrl } from "../utils/ws"; import { buildWsUrl } from "../utils/ws";
@@ -12,6 +12,21 @@ export function MessageComposer() {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]); const chunksRef = useRef<BlobPart[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isRecording, setIsRecording] = useState(false);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
function getWs(): WebSocket | null { function getWs(): WebSocket | null {
if (!accessToken || !activeChatId) { if (!accessToken || !activeChatId) {
@@ -40,18 +55,37 @@ export function MessageComposer() {
if (!activeChatId) { if (!activeChatId) {
return; return;
} }
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
try {
const upload = await requestUploadUrl(file); const upload = await requestUploadUrl(file);
await fetch(upload.upload_url, { await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
method: "PUT",
headers: upload.required_headers,
body: file
});
const message = await sendMessage(activeChatId, upload.file_url, messageType); const message = await sendMessage(activeChatId, upload.file_url, messageType);
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size); await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
prependMessage(activeChatId, message); prependMessage(activeChatId, message);
} catch {
setUploadError("Upload failed. Please try again.");
} finally {
setIsUploading(false);
}
}
function inferType(file: File): "file" | "image" | "video" | "audio" {
if (file.type.startsWith("image/")) {
return "image";
}
if (file.type.startsWith("video/")) {
return "video";
}
if (file.type.startsWith("audio/")) {
return "audio";
}
return "file";
} }
async function startRecord() { async function startRecord() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream); const recorder = new MediaRecorder(stream);
chunksRef.current = []; chunksRef.current = [];
@@ -59,15 +93,71 @@ export function MessageComposer() {
recorder.onstop = async () => { recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" }); const blob = new Blob(chunksRef.current, { type: "audio/webm" });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
setIsRecording(false);
await handleUpload(file, "voice"); await handleUpload(file, "voice");
}; };
recorderRef.current = recorder; recorderRef.current = recorder;
recorder.start(); recorder.start();
setIsRecording(true);
} catch {
setUploadError("Microphone access denied.");
}
} }
function stopRecord() { function stopRecord() {
recorderRef.current?.stop(); recorderRef.current?.stop();
recorderRef.current = null; recorderRef.current = null;
setIsRecording(false);
}
function selectFile(file: File) {
setUploadError(null);
setSelectedFile(file);
const fileType = inferType(file);
setSelectedType(fileType);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
if (fileType === "image" || fileType === "video") {
setPreviewUrl(URL.createObjectURL(file));
} else {
setPreviewUrl(null);
}
}
async function sendSelectedFile() {
if (!selectedFile) {
return;
}
await handleUpload(selectedFile, selectedType);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFile(null);
setPreviewUrl(null);
setSelectedType("file");
setUploadProgress(0);
}
function cancelSelectedFile() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFile(null);
setPreviewUrl(null);
setSelectedType("file");
setUploadProgress(0);
setUploadError(null);
}
function formatBytes(size: number): string {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
} }
return ( return (
@@ -89,24 +179,60 @@ export function MessageComposer() {
Send Send
</button> </button>
</div> </div>
{selectedFile ? (
<div className="mb-2 rounded border border-slate-700 bg-slate-900 p-3 text-sm">
<div className="mb-2 font-semibold">Ready to send</div>
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</div>
<div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</div>
{previewUrl && selectedType === "image" ? (
<img className="mb-2 max-h-56 rounded object-contain" src={previewUrl} alt={selectedFile.name} />
) : null}
{previewUrl && selectedType === "video" ? (
<video className="mb-2 max-h-56 w-full rounded" src={previewUrl} controls muted />
) : null}
{isUploading ? (
<div className="mb-2">
<div className="mb-1 text-xs text-slate-300">Uploading: {uploadProgress}%</div>
<div className="h-2 rounded bg-slate-700">
<div className="h-2 rounded bg-accent transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
) : null}
<div className="flex gap-2">
<button
className="rounded bg-accent px-3 py-1 font-semibold text-black disabled:opacity-50"
onClick={() => void sendSelectedFile()}
disabled={isUploading}
>
Send media
</button>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
Cancel
</button>
</div>
</div>
) : null}
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<label className="cursor-pointer rounded bg-slate-700 px-3 py-1"> <label className="cursor-pointer rounded bg-slate-700 px-3 py-1">
Upload Upload
<input <input
className="hidden" className="hidden"
type="file" type="file"
disabled={isUploading}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
void handleUpload(file, "file"); selectFile(file);
} }
e.currentTarget.value = "";
}} }}
/> />
</label> </label>
<button className="rounded bg-slate-700 px-3 py-1" onClick={startRecord}> <button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
Record Voice {isRecording ? "Recording..." : "Record Voice"}
</button> </button>
<button className="rounded bg-slate-700 px-3 py-1" onClick={stopRecord}> <button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
Stop Stop
</button> </button>
</div> </div>

View File

@@ -27,9 +27,9 @@ export function MessageList() {
<div className={`mb-3 flex ${message.sender_id === me?.id ? "justify-end" : "justify-start"}`} key={message.id}> <div className={`mb-3 flex ${message.sender_id === me?.id ? "justify-end" : "justify-start"}`} key={message.id}>
<div className="max-w-[80%] rounded-lg bg-slate-800 px-3 py-2"> <div className="max-w-[80%] rounded-lg bg-slate-800 px-3 py-2">
{message.type === "voice" && message.text ? ( {message.type === "voice" && message.text ? (
<audio controls src={message.text} /> renderContent(message.type, message.text)
) : ( ) : (
<p className="whitespace-pre-wrap break-words">{message.text}</p> renderContent(message.type, message.text)
)} )}
<p className="mt-1 text-right text-[11px] text-slate-400">{formatTime(message.created_at)}</p> <p className="mt-1 text-right text-[11px] text-slate-400">{formatTime(message.created_at)}</p>
</div> </div>
@@ -42,3 +42,25 @@ export function MessageList() {
</div> </div>
); );
} }
function renderContent(messageType: string, text: string | null) {
if (!text) {
return <p className="text-slate-300">[empty]</p>;
}
if (messageType === "image") {
return <img alt="attachment" className="max-h-64 rounded" src={text} />;
}
if (messageType === "video" || messageType === "circle_video") {
return <video className="max-h-64 rounded" controls src={text} />;
}
if (messageType === "audio" || messageType === "voice") {
return <audio controls src={text} />;
}
if (messageType === "file") {
return (
<a className="text-accent underline" href={text} rel="noreferrer" target="_blank">
Open file
</a>
);
}
return <p className="whitespace-pre-wrap break-words">{text}</p>;
}

View File

@@ -4,10 +4,10 @@ import { searchUsers } from "../api/users";
import type { ChatType, UserSearchItem } from "../chat/types"; import type { ChatType, UserSearchItem } from "../chat/types";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
type CreateMode = "private" | "group" | "channel"; type CreateMode = "group" | "channel";
export function NewChatPanel() { export function NewChatPanel() {
const [mode, setMode] = useState<CreateMode>("private"); const [mode, setMode] = useState<CreateMode>("group");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [results, setResults] = useState<UserSearchItem[]>([]); const [results, setResults] = useState<UserSearchItem[]>([]);
@@ -57,9 +57,6 @@ export function NewChatPanel() {
async function createByType(event: FormEvent) { async function createByType(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (mode === "private") {
return;
}
if (!title.trim()) { if (!title.trim()) {
setError("Title is required"); setError("Title is required");
return; return;
@@ -80,9 +77,6 @@ export function NewChatPanel() {
return ( return (
<div className="border-b border-slate-700 p-3"> <div className="border-b border-slate-700 p-3">
<div className="mb-2 flex gap-2 text-xs"> <div className="mb-2 flex gap-2 text-xs">
<button className={`rounded px-2 py-1 ${mode === "private" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("private")}>
Private
</button>
<button className={`rounded px-2 py-1 ${mode === "group" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("group")}> <button className={`rounded px-2 py-1 ${mode === "group" ? "bg-accent text-black" : "bg-slate-700"}`} onClick={() => setMode("group")}>
Group Group
</button> </button>
@@ -91,8 +85,8 @@ export function NewChatPanel() {
</button> </button>
</div> </div>
{mode === "private" ? ( <div className="mb-3 space-y-2">
<div className="space-y-2"> <p className="text-xs text-slate-400">Новый диалог</p>
<input <input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm" className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
placeholder="@username" placeholder="@username"
@@ -112,7 +106,6 @@ export function NewChatPanel() {
{normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null} {normalizedQuery && results.length === 0 ? <p className="text-xs text-slate-400">No users</p> : null}
</div> </div>
</div> </div>
) : (
<form className="space-y-2" onSubmit={(e) => void createByType(e)}> <form className="space-y-2" onSubmit={(e) => void createByType(e)}>
<input <input
className="w-full rounded bg-slate-800 px-2 py-1 text-sm" className="w-full rounded bg-slate-800 px-2 py-1 text-sm"
@@ -124,7 +117,6 @@ export function NewChatPanel() {
Create {mode} Create {mode}
</button> </button>
</form> </form>
)}
{error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null} {error ? <p className="mt-2 text-xs text-red-400">{error}</p> : null}
</div> </div>
); );

View File

@@ -14,6 +14,8 @@ export function useRealtime() {
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
const prependMessage = useChatStore((s) => s.prependMessage); const prependMessage = useChatStore((s) => s.prependMessage);
const loadChats = useChatStore((s) => s.loadChats);
const chats = useChatStore((s) => s.chats);
const typingByChat = useRef<Record<number, Set<number>>>({}); const typingByChat = useRef<Record<number, Set<number>>>({});
const wsUrl = useMemo(() => { const wsUrl = useMemo(() => {
@@ -32,6 +34,9 @@ export function useRealtime() {
const chatId = Number(event.payload.chat_id); const chatId = Number(event.payload.chat_id);
const message = event.payload.message as Message; const message = event.payload.message as Message;
prependMessage(chatId, message); prependMessage(chatId, message);
if (!chats.some((chat) => chat.id === chatId)) {
void loadChats();
}
} }
if (event.event === "typing_start") { if (event.event === "typing_start") {
const chatId = Number(event.payload.chat_id); const chatId = Number(event.payload.chat_id);
@@ -54,7 +59,7 @@ export function useRealtime() {
}; };
return () => ws.close(); return () => ws.close();
}, [wsUrl, prependMessage, me?.id]); }, [wsUrl, prependMessage, loadChats, chats, me?.id]);
return null; return null;
} }

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/authpanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/chats.ts","./src/api/http.ts","./src/api/users.ts","./src/app/app.tsx","./src/chat/types.ts","./src/components/authpanel.tsx","./src/components/chatlist.tsx","./src/components/messagecomposer.tsx","./src/components/messagelist.tsx","./src/components/newchatpanel.tsx","./src/hooks/userealtime.ts","./src/pages/authpage.tsx","./src/pages/chatspage.tsx","./src/store/authstore.ts","./src/store/chatstore.ts","./src/utils/format.ts","./src/utils/ws.ts"],"version":"5.9.2"}