feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s

This commit is contained in:
2026-03-08 10:20:52 +03:00
parent 52c41b6958
commit 6a96a99775
9 changed files with 857 additions and 212 deletions

View File

@@ -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()]

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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
]

View File

@@ -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<Chat> {
return data;
}
export async function getChatAttachments(chatId: number, limit = 100, beforeId?: number): Promise<ChatAttachment[]> {
const { data } = await http.get<ChatAttachment[]>(`/media/chats/${chatId}/attachments`, {
params: {
limit,
before_id: beforeId
}
});
return data;
}
export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
}

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
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(
<div className="fixed inset-0 z-[120] bg-slate-950/55" onClick={onClose}>
<div
className="fixed inset-0 z-[120] bg-slate-950/55"
onClick={() => {
setAttachmentCtx(null);
onClose();
}}
>
<aside className="absolute right-0 top-0 h-full w-full max-w-sm border-l border-slate-700/70 bg-slate-900/95 p-4 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold">Chat info</p>
@@ -321,6 +339,65 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</div>
) : null}
<div className="mb-3 rounded-lg border border-slate-700/70 bg-slate-800/60 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
Media & Files {attachments.length > 0 ? `(${attachments.length})` : ""}
</p>
{attachmentsLoading ? <p className="text-xs text-slate-400">Loading attachments...</p> : null}
{!attachmentsLoading && attachments.length === 0 ? <p className="text-xs text-slate-400">No attachments yet</p> : null}
{!attachmentsLoading ? (
<>
<div className="mb-2 grid grid-cols-3 gap-1">
{attachments
.filter((item) => item.file_type.startsWith("image/"))
.slice(0, 9)
.map((item) => (
<button
className="group relative aspect-square overflow-hidden rounded-md border border-slate-700/70 bg-slate-900"
key={`media-image-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
});
}}
type="button"
>
<img alt="attachment" className="h-full w-full object-cover transition group-hover:scale-105" src={item.file_url} />
</button>
))}
</div>
<div className="tg-scrollbar max-h-44 space-y-1 overflow-auto">
{attachments.slice(0, 40).map((item) => (
<button
className="block w-full rounded-md border border-slate-700/60 bg-slate-900/70 px-2 py-1.5 text-left hover:bg-slate-700/70"
key={`media-item-${item.id}`}
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
onContextMenu={(event) => {
event.preventDefault();
setAttachmentCtx({
x: Math.min(event.clientX + 4, window.innerWidth - 190),
y: Math.min(event.clientY + 4, window.innerHeight - 120),
url: item.file_url
});
}}
type="button"
>
<p className="truncate text-xs font-semibold text-slate-200">{extractFileName(item.file_url)}</p>
<p className="text-[11px] text-slate-400">
{attachmentKind(item.file_type)} {formatBytes(item.file_size)}
</p>
</button>
))}
</div>
</>
) : null}
</div>
{chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
<button
className="mb-3 w-full rounded bg-slate-700 px-3 py-2 text-sm disabled:opacity-60"
@@ -366,6 +443,34 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</>
) : null}
</aside>
{attachmentCtx ? (
<div
className="fixed z-[130] w-44 rounded-lg border border-slate-700/90 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
Open
</a>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
Download
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
} catch {
return;
} finally {
setAttachmentCtx(null);
}
}}
type="button"
>
Copy link
</button>
</div>
) : null}
</div>,
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";
}

View File

@@ -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<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const recordingStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const sendVoiceOnStopRef = useRef<boolean>(true);
const recordingStartedAtRef = useRef<number | null>(null);
const pointerStartYRef = useRef<number>(0);
const pointerStartXRef = useRef<number>(0);
const pointerCancelArmedRef = useRef<boolean>(false);
const pointerMoveHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
const pointerUpHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
const recordingStateRef = useRef<RecordingState>("idle");
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);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [captionDraft, setCaptionDraft] = useState("");
const mediaInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [recordingState, setRecordingState] = useState<RecordingState>("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<HTMLButtonElement>) {
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() {
</button>
</div>
) : null}
<div className="mb-2 flex items-center gap-2">
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
+
{recordingState !== "idle" ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-200">
<div className="flex items-center justify-between">
<span>🎤 Recording {formatDuration(recordSeconds)}</span>
{recordingState === "recording" ? (
<span className={dragHint === "cancel" ? "text-red-300" : dragHint === "lock" ? "text-sky-300" : "text-slate-400"}>
{dragHint === "cancel" ? "Release to cancel" : dragHint === "lock" ? "Release up to lock" : "Slide up to lock, left to cancel"}
</span>
) : (
<span className="text-sky-300">Locked</span>
)}
</div>
{recordingState === "locked" ? (
<div className="mt-2 flex gap-2">
<button className="w-full rounded bg-slate-700 px-3 py-1.5" onClick={() => stopRecord(false)} type="button">
Cancel
</button>
<button className="w-full rounded bg-sky-500 px-3 py-1.5 font-semibold text-slate-950" onClick={() => stopRecord(true)} type="button">
Send Voice
</button>
</div>
) : null}
</div>
) : null}
<div className="mb-2 flex items-end gap-2">
<div className="relative">
<button
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 disabled:opacity-60"
disabled={isUploading || recordingState !== "idle"}
onClick={() => setShowAttachMenu((v) => !v)}
type="button"
>
📎
</button>
{showAttachMenu ? (
<div className="absolute bottom-12 left-0 z-20 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1.5 shadow-2xl">
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => mediaInputRef.current?.click()}
type="button"
>
Photo or Video
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => fileInputRef.current?.click()}
type="button"
>
File
</button>
</div>
) : null}
<input
ref={mediaInputRef}
className="hidden"
type="file"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
accept="image/*,video/*"
disabled={isUploading || recordingState !== "idle"}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
selectFile(file);
}
e.currentTarget.value = "";
event.currentTarget.value = "";
}}
/>
</label>
<input
ref={fileInputRef}
className="hidden"
type="file"
disabled={isUploading || recordingState !== "idle"}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
selectFile(file);
}
event.currentTarget.value = "";
}}
/>
</div>
<textarea
className="flex-1 resize-none rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Write a message..."
rows={1}
value={text}
onKeyDown={onComposerKeyDown}
onChange={(e) => {
const next = e.target.value;
onChange={(event) => {
const next = event.target.value;
setText(next);
if (activeChatId) {
setDraft(activeChatId, next);
}
if (activeChatId) {
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
}
}}
/>
<button className="rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400" onClick={handleSend}>
<button
className="h-[42px] w-[72px] rounded-full bg-sky-500 px-4 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
disabled={recordingState !== "idle"}
onClick={handleSend}
type="button"
>
Send
</button>
<button
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"}`}
disabled={isUploading}
onPointerDown={onMicPointerDown}
type="button"
>
Mic
</button>
</div>
{selectedFile ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 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>
<div className="mb-2 rounded-2xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm shadow-xl">
<div className="mb-2 flex items-center justify-between">
<div>
<p className="font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
<p className="text-xs text-slate-400">{selectedFile.name} {formatBytes(selectedFile.size)}</p>
</div>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
</button>
</div>
{previewUrl && selectedType === "image" ? (
<img className="mb-2 max-h-56 rounded object-contain" src={previewUrl} alt={selectedFile.name} />
<img className="mb-2 max-h-72 w-full rounded-xl 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 />
<video className="mb-2 max-h-72 w-full rounded-xl" src={previewUrl} controls muted />
) : null}
{!previewUrl ? (
<div className="mb-2 rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
No preview available
</div>
) : null}
<input
className="mb-2 w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
maxLength={1000}
placeholder="Add a caption..."
value={captionDraft}
onChange={(event) => setCaptionDraft(event.target.value)}
/>
{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 className="h-2 rounded bg-sky-500 transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
) : null}
<div className="flex gap-2">
<button
className="rounded-lg bg-sky-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-50"
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
onClick={() => void sendSelectedFile()}
disabled={isUploading}
>
Send media
Send
</button>
<button className="rounded-lg bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
<button className="w-full rounded-xl bg-slate-700 px-3 py-2 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">
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
{isRecording ? "Recording..." : "Record Voice"}
</button>
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
Stop
</button>
</div>
</div>
);
}
function formatDuration(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

View File

@@ -1,6 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { createPortal } from "react-dom";
import { deleteMessage, forwardMessageBulk, listMessageReactions, pinMessage, toggleMessageReaction } from "../api/chats";
import {
deleteMessage,
forwardMessageBulk,
listMessageReactions,
pinMessage,
toggleMessageReaction
} from "../api/chats";
import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
@@ -13,6 +19,12 @@ type ContextMenuState = {
messageId: number;
} | null;
type AttachmentMenuState = {
x: number;
y: number;
url: string;
} | null;
type PendingDeleteState = {
chatId: number;
messages: Message[];
@@ -20,6 +32,8 @@ type PendingDeleteState = {
timerId: number;
} | null;
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
export function MessageList() {
const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId);
@@ -36,7 +50,9 @@ export function MessageList() {
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [attachmentCtx, setAttachmentCtx] = useState<AttachmentMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null);
@@ -72,10 +88,7 @@ export function MessageList() {
const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null;
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo(
() => messages.filter((m) => selectedIds.has(m.id)),
[messages, selectedIds]
);
const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]);
const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id]
@@ -87,6 +100,7 @@ export function MessageList() {
return;
}
setCtx(null);
setAttachmentCtx(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
setDeleteMessageId(null);
@@ -99,6 +113,7 @@ export function MessageList() {
useEffect(() => {
setSelectedIds(new Set());
setCtx(null);
setAttachmentCtx(null);
setDeleteMessageId(null);
setForwardMessageId(null);
setForwardSelectedChatIds(new Set());
@@ -131,6 +146,27 @@ export function MessageList() {
}
const chatId = activeChatId;
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleForwardSubmit() {
if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds];
@@ -174,27 +210,6 @@ export function MessageList() {
}
}
async function ensureReactionsLoaded(messageId: number) {
if (reactionsByMessage[messageId]) {
return;
}
try {
const rows = await listMessageReactions(messageId);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
async function handleToggleReaction(messageId: number, emoji: string) {
try {
const rows = await toggleMessageReaction(messageId, emoji);
setReactionsByMessage((state) => ({ ...state, [messageId]: rows }));
} catch {
return;
}
}
function toggleSelected(messageId: number) {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -283,7 +298,7 @@ export function MessageList() {
}
return (
<div className="flex h-full flex-col" onClick={() => setCtx(null)}>
<div className="flex h-full flex-col" onClick={() => { setCtx(null); setAttachmentCtx(null); }}>
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
@@ -307,7 +322,8 @@ export function MessageList() {
</div>
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
<div className="tg-scrollbar flex-1 overflow-auto px-2 py-4 md:px-5">
{hasMore ? (
<div className="mb-3 flex justify-center">
<button
@@ -319,10 +335,13 @@ export function MessageList() {
</button>
</div>
) : null}
{messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
return (
<div key={`${message.id}-${message.client_message_id ?? ""}`}>
{unreadBoundaryIndex === messageIndex ? (
@@ -334,143 +353,222 @@ export function MessageList() {
<span className="h-px flex-1 bg-slate-700/60" />
</div>
) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div
id={`message-${message.id}`}
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${
own ? "rounded-br-md bg-sky-500/90 text-slate-950" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);
}
}}
onContextMenu={(e) => {
e.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
>
{selectedIds.size > 0 ? (
<div className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"}`}>
{isSelected ? "✓" : ""}
</div>
) : null}
{message.forwarded_from_message_id ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-sky-300"}`}>
Forwarded message
</div>
) : null}
{replySource ? (
<div className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${own ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75" : "border-sky-400 bg-slate-800/60 text-slate-300"}`}>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex flex-wrap gap-1">
{["👍", "❤️", "🔥"].map((emoji) => {
const items = reactionsByMessage[message.id] ?? [];
const item = items.find((reaction) => reaction.emoji === emoji);
return (
<button
className={`rounded-full border px-2 py-0.5 text-[11px] ${
item?.reacted ? "border-sky-300 bg-sky-500/30" : "border-slate-500/60 bg-slate-700/40"
}`}
key={`${message.id}-${emoji}`}
onClick={() => void handleToggleReaction(message.id, emoji)}
type="button"
>
{emoji}{item ? ` ${item.count}` : ""}
</button>
);
<div
id={`message-${message.id}`}
className={`max-w-[90%] rounded-2xl px-3 py-2.5 shadow-sm md:max-w-[70%] ${
own
? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
toggleSelected(message.id);
}
}}
onContextMenu={(event) => {
event.preventDefault();
void ensureReactionsLoaded(message.id);
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
setCtx({ x: pos.x, y: pos.y, messageId: message.id });
}}
>
{selectedIds.size > 0 ? (
<div
className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${
isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"
}`}
>
{isSelected ? "✓" : ""}
</div>
) : null}
{message.forwarded_from_message_id ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "border-sky-400 bg-slate-800/60 text-sky-300"
}`}
>
Forwarded message
</div>
) : null}
{replySource ? (
<div
className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
own
? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
: "border-sky-400 bg-slate-800/60 text-slate-300"
}`}
>
<p className="truncate font-semibold">{replySource.sender_id === me?.id ? "You" : "Reply"}</p>
<p className="truncate">{replySource.text || "[media]"}</p>
</div>
) : null}
{renderMessageContent(message.type, message.text, {
onAttachmentContextMenu: (event, url) => {
event.preventDefault();
const pos = getSafeContextPosition(event.clientX, event.clientY, 180, 110);
setAttachmentCtx({ x: pos.x, y: pos.y, url });
}
})}
{messageReactions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
{messageReactions.map((reaction) => (
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
reaction.reacted
? "border-sky-300 bg-sky-500/30"
: own
? "border-slate-900/30 bg-slate-900/10"
: "border-slate-600/60 bg-slate-800/60"
}`}
key={`${message.id}-${reaction.emoji}`}
>
{reaction.emoji} {reaction.count}
</span>
))}
</div>
) : null}
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? (
<span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span>
) : null}
</p>
</div>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p>
</div>
</div>
</div>
);
})}
</div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx
? createPortal(
<div
className="fixed z-[100] w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
<div
className="fixed z-[110] w-56 rounded-xl border border-slate-700/90 bg-slate-900/95 p-1.5 shadow-2xl"
style={{ left: ctx.x, top: ctx.y }}
onClick={(event) => event.stopPropagation()}
>
Reply
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setForwardMessageId(ctx.messageId);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
<div className="mb-1 flex flex-wrap gap-1 rounded-lg bg-slate-800/80 p-1">
{QUICK_REACTIONS.map((emoji) => (
<button
className="rounded-md px-1.5 py-1 text-sm hover:bg-slate-700"
key={emoji}
onClick={() => void handleToggleReaction(ctx.messageId, emoji)}
type="button"
>
{emoji}
</button>
))}
</div>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
const msg = messagesMap.get(ctx.messageId);
if (msg) {
setReplyToMessage(chatId, msg);
}
setCtx(null);
}}
>
Reply
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setForwardMessageId(ctx.messageId);
setForwardQuery("");
setForwardError(null);
setForwardSelectedChatIds(new Set());
setCtx(null);
}}
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setSelectedIds(new Set([ctx.messageId]));
setCtx(null);
}}
>
Select
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>,
document.body
)
: null}
{attachmentCtx
? createPortal(
<div
className="fixed z-[111] w-44 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl"
style={{ left: attachmentCtx.x, top: attachmentCtx.y }}
onClick={(event) => event.stopPropagation()}
>
Forward
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={() => {
setSelectedIds(new Set([ctx.messageId]));
setCtx(null);
}}
>
Select
</button>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={() => {
setDeleteMessageId(ctx.messageId);
setDeleteError(null);
setCtx(null);
}}
>
Delete
</button>
<button className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" onClick={() => void handlePin(ctx.messageId)}>
Pin / Unpin
</button>
</div>,
document.body
)
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
Open
</a>
<a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
Download
</a>
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
} catch {
return;
}
}}
type="button"
>
Copy link
</button>
</div>,
document.body
)
: null}
{forwardMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setForwardMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Forward message</p>
<input
className="mb-2 w-full rounded-lg border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder="Search chats"
value={forwardQuery}
onChange={(e) => setForwardQuery(e.target.value)}
onChange={(event) => setForwardQuery(event.target.value)}
/>
<div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => (
<button
className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"}`}
className={`block w-full rounded-lg px-3 py-2 text-left text-sm disabled:opacity-60 ${
forwardSelectedChatIds.has(chat.id) ? "bg-sky-500/30" : "bg-slate-800 hover:bg-slate-700"
}`}
disabled={isForwarding}
key={chat.id}
onClick={() => {
@@ -506,7 +604,7 @@ export function MessageList() {
{deleteMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(e) => e.stopPropagation()}>
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
<p className="mb-2 text-sm font-semibold">Delete message</p>
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
<div className="space-y-2">
@@ -544,18 +642,70 @@ export function MessageList() {
);
}
function renderContent(messageType: string, text: string | null) {
function renderMessageContent(
messageType: string,
text: string | null,
opts: { onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void }
) {
if (!text) return <p className="opacity-80">[empty]</p>;
if (messageType === "image") return <img alt="attachment" className="max-h-72 rounded-lg object-cover" src={text} />;
if (messageType === "video" || messageType === "circle_video") return <video className="max-h-72 rounded-lg" controls src={text} />;
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
if (messageType === "image") {
return (
<a className="underline" href={text} rel="noreferrer" target="_blank">
Open file
</a>
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
</div>
);
}
if (messageType === "video" || messageType === "circle_video") {
return (
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<video className="max-h-80 rounded-xl" controls src={text} />
</div>
);
}
if (messageType === "voice") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
<span className="font-semibold">Voice message</span>
</div>
<audio className="w-full" controls preload="metadata" src={text} />
</div>
);
}
if (messageType === "audio") {
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎵</span>
<div className="min-w-0">
<p className="truncate font-semibold text-slate-200">{extractFileName(text)}</p>
<p className="text-[11px] text-slate-400">Audio file</p>
</div>
</div>
<audio className="w-full" controls preload="metadata" src={text} />
</div>
);
}
if (messageType === "file") {
return (
<button
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
type="button"
>
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(text)}</p>
</button>
);
}
return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
}
@@ -595,3 +745,14 @@ function canDeleteForEveryone(
if (chat.type === "private") return true;
return message.sender_id === meId;
}
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";
}
}