feat: improve chat realtime and media composer UX
All checks were successful
CI / test (push) Successful in 27s
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:
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user