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.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession
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())
)
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,
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:
raise HTTPException(
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.messages.schemas import MessageCreateRequest, MessageRead, MessageUpdateRequest
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
router = APIRouter(prefix="/messages", tags=["messages"])
@@ -16,7 +17,9 @@ async def create_message(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> 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])

View File

@@ -83,17 +83,7 @@ class RealtimeGateway:
sender_id=user_id,
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_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,
},
)
await self.publish_message_created(message=message, sender_id=user_id, temp_id=payload.temp_id)
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)
@@ -149,6 +139,19 @@ class RealtimeGateway:
return
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:
user_connections = self._connections.get(user_id, {})
if not user_connections: