diff --git a/app/media/repository.py b/app/media/repository.py index a832717..0c96844 100644 --- a/app/media/repository.py +++ b/app/media/repository.py @@ -1,6 +1,8 @@ +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.media.models import Attachment +from app.messages.models import Message async def create_attachment( @@ -24,3 +26,23 @@ async def create_attachment( async def get_attachment_by_id(db: AsyncSession, attachment_id: int) -> Attachment | None: return await db.get(Attachment, attachment_id) + + +async def list_chat_attachments( + db: AsyncSession, + *, + chat_id: int, + limit: int = 100, + before_id: int | None = None, +) -> list[tuple[Attachment, Message]]: + stmt = ( + select(Attachment, Message) + .join(Message, Message.id == Attachment.message_id) + .where(Message.chat_id == chat_id) + .order_by(Attachment.id.desc()) + .limit(limit) + ) + if before_id is not None: + stmt = stmt.where(Attachment.id < before_id) + result = await db.execute(stmt) + return [(row[0], row[1]) for row in result.all()] diff --git a/app/media/router.py b/app/media/router.py index 457b91b..61af20e 100644 --- a/app/media/router.py +++ b/app/media/router.py @@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db -from app.media.schemas import AttachmentCreateRequest, AttachmentRead, UploadUrlRequest, UploadUrlResponse -from app.media.service import generate_upload_url, store_attachment_metadata +from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse +from app.media.service import generate_upload_url, list_attachments_for_chat, store_attachment_metadata from app.users.models import User router = APIRouter(prefix="/media", tags=["media"]) @@ -25,3 +25,20 @@ async def create_attachment_metadata( current_user: User = Depends(get_current_user), ) -> AttachmentRead: return await store_attachment_metadata(db, user_id=current_user.id, payload=payload) + + +@router.get("/chats/{chat_id}/attachments", response_model=list[ChatAttachmentRead]) +async def list_chat_attachments_endpoint( + chat_id: int, + limit: int = 100, + before_id: int | None = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[ChatAttachmentRead]: + return await list_attachments_for_chat( + db, + user_id=current_user.id, + chat_id=chat_id, + limit=limit, + before_id=before_id, + ) diff --git a/app/media/schemas.py b/app/media/schemas.py index 03a9b1d..b0c83bc 100644 --- a/app/media/schemas.py +++ b/app/media/schemas.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime class UploadUrlRequest(BaseModel): @@ -30,3 +31,13 @@ class AttachmentRead(BaseModel): file_url: str file_type: str file_size: int + + +class ChatAttachmentRead(BaseModel): + id: int + message_id: int + sender_id: int + message_created_at: datetime + file_url: str + file_type: str + file_size: int diff --git a/app/media/service.py b/app/media/service.py index fcaaaa9..eeef756 100644 --- a/app/media/service.py +++ b/app/media/service.py @@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config.settings import settings from app.media import repository -from app.media.schemas import AttachmentCreateRequest, AttachmentRead, UploadUrlRequest, UploadUrlResponse +from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse +from app.chats.service import ensure_chat_membership from app.messages.repository import get_message_by_id ALLOWED_MIME_TYPES = { @@ -134,3 +135,32 @@ async def store_attachment_metadata( await db.commit() await db.refresh(attachment) return AttachmentRead.model_validate(attachment) + + +async def list_attachments_for_chat( + db: AsyncSession, + *, + user_id: int, + chat_id: int, + limit: int = 100, + before_id: int | None = None, +) -> list[ChatAttachmentRead]: + await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id) + rows = await repository.list_chat_attachments( + db, + chat_id=chat_id, + limit=max(1, min(limit, 200)), + before_id=before_id, + ) + return [ + ChatAttachmentRead( + id=attachment.id, + message_id=attachment.message_id, + sender_id=message.sender_id, + message_created_at=message.created_at, + file_url=attachment.file_url, + file_type=attachment.file_type, + file_size=attachment.file_size, + ) + for attachment, message in rows + ] diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 795ee9e..c92c6c5 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -1,5 +1,17 @@ import { http } from "./http"; -import type { Chat, ChatDetail, ChatInviteLink, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageReaction, MessageType } from "../chat/types"; +import type { + Chat, + ChatAttachment, + ChatDetail, + ChatInviteLink, + ChatMember, + ChatMemberRole, + ChatType, + DiscoverChat, + Message, + MessageReaction, + MessageType +} from "../chat/types"; import axios from "axios"; export interface ChatNotificationSettings { @@ -225,6 +237,16 @@ export async function joinByInvite(token: string): Promise { return data; } +export async function getChatAttachments(chatId: number, limit = 100, beforeId?: number): Promise { + const { data } = await http.get(`/media/chats/${chatId}/attachments`, { + params: { + limit, + before_id: beforeId + } + }); + return data; +} + export async function deleteMessage(messageId: number, forAll = false): Promise { await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 22359c7..65200c2 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -97,3 +97,13 @@ export interface ChatInviteLink { token: string; invite_url: string; } + +export interface ChatAttachment { + id: number; + message_id: number; + sender_id: number; + message_created_at: string; + file_url: string; + file_type: string; + file_size: number; +} diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index c0e4c01..4db4c43 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { addChatMember, createInviteLink, + getChatAttachments, getChatNotificationSettings, getChatDetail, leaveChat, @@ -13,7 +14,7 @@ import { updateChatTitle } from "../api/chats"; import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; -import type { AuthUser, ChatDetail, ChatMember, UserSearchItem } from "../chat/types"; +import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; @@ -41,6 +42,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { const [counterpartBlocked, setCounterpartBlocked] = useState(false); const [savingBlock, setSavingBlock] = useState(false); const [inviteLink, setInviteLink] = useState(null); + const [attachments, setAttachments] = useState([]); + const [attachmentsLoading, setAttachmentsLoading] = useState(false); + const [attachmentCtx, setAttachmentCtx] = useState<{ x: number; y: number; url: string } | null>(null); const myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]); const isGroupLike = chat?.type === "group" || chat?.type === "channel"; @@ -67,6 +71,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { let cancelled = false; setLoading(true); setError(null); + setAttachmentsLoading(true); void (async () => { try { const detail = await getChatDetail(chatId); @@ -92,10 +97,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { setCounterpartBlocked(false); } await refreshMembers(chatId); + const chatAttachments = await getChatAttachments(chatId, 120); + if (!cancelled) { + setAttachments(chatAttachments); + } } catch { if (!cancelled) setError("Failed to load chat info"); } finally { - if (!cancelled) setLoading(false); + if (!cancelled) { + setLoading(false); + setAttachmentsLoading(false); + } } })(); return () => { @@ -120,7 +132,13 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { } return createPortal( -
+
{ + setAttachmentCtx(null); + onClose(); + }} + > + {attachmentCtx ? ( +
+ + Open + + + Download + + +
+ ) : null}
, document.body ); @@ -383,3 +488,28 @@ function formatLastSeen(value: string): string { minute: "2-digit" }); } + +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`; +} + +function extractFileName(url: string): string { + try { + const parsed = new URL(url); + const value = parsed.pathname.split("/").pop(); + return decodeURIComponent(value || "file"); + } catch { + const value = url.split("/").pop(); + return value ? decodeURIComponent(value) : "file"; + } +} + +function attachmentKind(fileType: string): string { + if (fileType.startsWith("image/")) return "Photo"; + if (fileType.startsWith("video/")) return "Video"; + if (fileType.startsWith("audio/")) return "Audio"; + if (fileType === "application/pdf") return "PDF"; + return "File"; +} diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index 3604977..a1414ba 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -1,9 +1,11 @@ -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { buildWsUrl } from "../utils/ws"; +type RecordingState = "idle" | "recording" | "locked"; + export function MessageComposer() { const activeChatId = useChatStore((s) => s.activeChatId); const me = useAuthStore((s) => s.me); @@ -16,17 +18,39 @@ export function MessageComposer() { const replyToByChat = useChatStore((s) => s.replyToByChat); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const accessToken = useAuthStore((s) => s.accessToken); + const [text, setText] = useState(""); const wsRef = useRef(null); const recorderRef = useRef(null); + const recordingStreamRef = useRef(null); const chunksRef = useRef([]); + const sendVoiceOnStopRef = useRef(true); + const recordingStartedAtRef = useRef(null); + const pointerStartYRef = useRef(0); + const pointerStartXRef = useRef(0); + const pointerCancelArmedRef = useRef(false); + const pointerMoveHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null); + const pointerUpHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null); + const recordingStateRef = useRef("idle"); + const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file"); const [previewUrl, setPreviewUrl] = useState(null); - const [isRecording, setIsRecording] = useState(false); + const [showAttachMenu, setShowAttachMenu] = useState(false); + const [captionDraft, setCaptionDraft] = useState(""); + const mediaInputRef = useRef(null); + const fileInputRef = useRef(null); + + const [recordingState, setRecordingState] = useState("idle"); + const [recordSeconds, setRecordSeconds] = useState(0); + const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle"); + + useEffect(() => { + recordingStateRef.current = recordingState; + }, [recordingState]); useEffect(() => { if (!activeChatId) { @@ -46,9 +70,29 @@ export function MessageComposer() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } + if (pointerMoveHandlerRef.current) { + window.removeEventListener("pointermove", pointerMoveHandlerRef.current); + } + if (pointerUpHandlerRef.current) { + window.removeEventListener("pointerup", pointerUpHandlerRef.current); + } }; }, [previewUrl]); + useEffect(() => { + if (recordingState === "idle") { + return; + } + const interval = window.setInterval(() => { + if (!recordingStartedAtRef.current) { + return; + } + const sec = Math.max(0, Math.floor((Date.now() - recordingStartedAtRef.current) / 1000)); + setRecordSeconds(sec); + }, 250); + return () => window.clearInterval(interval); + }, [recordingState]); + function makeClientMessageId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -147,15 +191,9 @@ export function MessageComposer() { } 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"; - } + if (file.type.startsWith("image/")) return "image"; + if (file.type.startsWith("video/")) return "video"; + if (file.type.startsWith("audio/")) return "audio"; return "file"; } @@ -209,41 +247,136 @@ export function MessageComposer() { } async function startRecord() { + if (recordingState !== "idle") { + return false; + } try { if (navigator.permissions && navigator.permissions.query) { const permission = await navigator.permissions.query({ name: "microphone" as PermissionName }); if (permission.state === "denied") { setUploadError("Microphone access denied. Allow microphone in browser site permissions."); - return; + return false; } } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); + recordingStreamRef.current = stream; chunksRef.current = []; - recorder.ondataavailable = (e) => chunksRef.current.push(e.data); + sendVoiceOnStopRef.current = true; + recorder.ondataavailable = (event) => chunksRef.current.push(event.data); recorder.onstop = async () => { - const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + const shouldSend = sendVoiceOnStopRef.current; + const data = [...chunksRef.current]; + chunksRef.current = []; + if (recordingStreamRef.current) { + recordingStreamRef.current.getTracks().forEach((track) => track.stop()); + recordingStreamRef.current = null; + } + if (!shouldSend || data.length === 0) { + return; + } + const blob = new Blob(data, { type: "audio/webm" }); const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" }); - setIsRecording(false); await handleUpload(file, "voice"); }; recorderRef.current = recorder; recorder.start(); - setIsRecording(true); + recordingStartedAtRef.current = Date.now(); + setRecordSeconds(0); + setRecordingState("recording"); + return true; } catch { setUploadError("Microphone access denied. Please allow microphone and retry."); + return false; } } - function stopRecord() { - recorderRef.current?.stop(); + function stopRecord(send: boolean) { + sendVoiceOnStopRef.current = send; + pointerCancelArmedRef.current = false; + setDragHint("idle"); + if (recorderRef.current && recorderRef.current.state !== "inactive") { + recorderRef.current.stop(); + } recorderRef.current = null; - setIsRecording(false); + recordingStartedAtRef.current = null; + setRecordingState("idle"); + setRecordSeconds(0); + } + + async function onMicPointerDown(event: PointerEvent) { + event.preventDefault(); + const started = await startRecord(); + if (!started) { + return; + } + pointerStartYRef.current = event.clientY; + pointerStartXRef.current = event.clientX; + pointerCancelArmedRef.current = false; + setDragHint("idle"); + + if (pointerMoveHandlerRef.current) { + window.removeEventListener("pointermove", pointerMoveHandlerRef.current); + } + if (pointerUpHandlerRef.current) { + window.removeEventListener("pointerup", pointerUpHandlerRef.current); + } + + const onPointerMove = (moveEvent: globalThis.PointerEvent) => { + const current = recordingStateRef.current; + if (current === "idle") { + return; + } + const deltaY = pointerStartYRef.current - moveEvent.clientY; + const deltaX = pointerStartXRef.current - moveEvent.clientX; + + if (current === "recording" && deltaY > 70) { + setRecordingState("locked"); + setDragHint("idle"); + return; + } + + if (current === "recording") { + if (deltaX > 90) { + pointerCancelArmedRef.current = true; + setDragHint("cancel"); + } else if (deltaY > 40) { + pointerCancelArmedRef.current = false; + setDragHint("lock"); + } else { + pointerCancelArmedRef.current = false; + setDragHint("idle"); + } + } + }; + + const onPointerUp = () => { + if (pointerMoveHandlerRef.current) { + window.removeEventListener("pointermove", pointerMoveHandlerRef.current); + } + if (pointerUpHandlerRef.current) { + window.removeEventListener("pointerup", pointerUpHandlerRef.current); + } + pointerMoveHandlerRef.current = null; + pointerUpHandlerRef.current = null; + + const current = recordingStateRef.current; + if (current === "recording") { + stopRecord(!pointerCancelArmedRef.current); + } + setDragHint("idle"); + }; + + pointerMoveHandlerRef.current = onPointerMove; + pointerUpHandlerRef.current = onPointerUp; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); } function selectFile(file: File) { setUploadError(null); setSelectedFile(file); + setShowAttachMenu(false); const fileType = inferType(file); setSelectedType(fileType); if (previewUrl) { @@ -262,11 +395,23 @@ export function MessageComposer() { } const uploadFile = await prepareFileForUpload(selectedFile, selectedType); await handleUpload(uploadFile, selectedType); + if (captionDraft.trim() && activeChatId && me) { + const clientMessageId = makeClientMessageId(); + const textValue = captionDraft.trim(); + addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId }); + try { + const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId); + confirmMessageByClientId(activeChatId, clientMessageId, message); + } catch { + removeOptimisticMessage(activeChatId, clientMessageId); + } + } if (previewUrl) { URL.revokeObjectURL(previewUrl); } setSelectedFile(null); setPreviewUrl(null); + setCaptionDraft(""); setSelectedType("file"); setUploadProgress(0); } @@ -277,18 +422,15 @@ export function MessageComposer() { } setSelectedFile(null); setPreviewUrl(null); + setCaptionDraft(""); 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`; - } + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`; } @@ -305,86 +447,186 @@ export function MessageComposer() {
) : null} -
-