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 sqlalchemy.ext.asyncio import AsyncSession
from app.media.models import Attachment from app.media.models import Attachment
from app.messages.models import Message
async def create_attachment( 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: async def get_attachment_by_id(db: AsyncSession, attachment_id: int) -> Attachment | None:
return await db.get(Attachment, attachment_id) 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.auth.service import get_current_user
from app.database.session import get_db from app.database.session import get_db
from app.media.schemas import AttachmentCreateRequest, AttachmentRead, UploadUrlRequest, UploadUrlResponse from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse
from app.media.service import generate_upload_url, store_attachment_metadata from app.media.service import generate_upload_url, list_attachments_for_chat, store_attachment_metadata
from app.users.models import User from app.users.models import User
router = APIRouter(prefix="/media", tags=["media"]) router = APIRouter(prefix="/media", tags=["media"])
@@ -25,3 +25,20 @@ async def create_attachment_metadata(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> AttachmentRead: ) -> AttachmentRead:
return await store_attachment_metadata(db, user_id=current_user.id, payload=payload) 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 pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
class UploadUrlRequest(BaseModel): class UploadUrlRequest(BaseModel):
@@ -30,3 +31,13 @@ class AttachmentRead(BaseModel):
file_url: str file_url: str
file_type: str file_type: str
file_size: int 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.config.settings import settings
from app.media import repository 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 from app.messages.repository import get_message_by_id
ALLOWED_MIME_TYPES = { ALLOWED_MIME_TYPES = {
@@ -134,3 +135,32 @@ async def store_attachment_metadata(
await db.commit() await db.commit()
await db.refresh(attachment) await db.refresh(attachment)
return AttachmentRead.model_validate(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 { 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"; import axios from "axios";
export interface ChatNotificationSettings { export interface ChatNotificationSettings {
@@ -225,6 +237,16 @@ export async function joinByInvite(token: string): Promise<Chat> {
return data; 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> { export async function deleteMessage(messageId: number, forAll = false): Promise<void> {
await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } }); await http.delete(`/messages/${messageId}`, { params: { for_all: forAll } });
} }

View File

@@ -97,3 +97,13 @@ export interface ChatInviteLink {
token: string; token: string;
invite_url: 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 { import {
addChatMember, addChatMember,
createInviteLink, createInviteLink,
getChatAttachments,
getChatNotificationSettings, getChatNotificationSettings,
getChatDetail, getChatDetail,
leaveChat, leaveChat,
@@ -13,7 +14,7 @@ import {
updateChatTitle updateChatTitle
} from "../api/chats"; } from "../api/chats";
import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } from "../api/users"; 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 { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
@@ -41,6 +42,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const [counterpartBlocked, setCounterpartBlocked] = useState(false); const [counterpartBlocked, setCounterpartBlocked] = useState(false);
const [savingBlock, setSavingBlock] = useState(false); const [savingBlock, setSavingBlock] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null); 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 myRole = useMemo(() => members.find((m) => m.user_id === me?.id)?.role, [members, me?.id]);
const isGroupLike = chat?.type === "group" || chat?.type === "channel"; const isGroupLike = chat?.type === "group" || chat?.type === "channel";
@@ -67,6 +71,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError(null); setError(null);
setAttachmentsLoading(true);
void (async () => { void (async () => {
try { try {
const detail = await getChatDetail(chatId); const detail = await getChatDetail(chatId);
@@ -92,10 +97,17 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setCounterpartBlocked(false); setCounterpartBlocked(false);
} }
await refreshMembers(chatId); await refreshMembers(chatId);
const chatAttachments = await getChatAttachments(chatId, 120);
if (!cancelled) {
setAttachments(chatAttachments);
}
} catch { } catch {
if (!cancelled) setError("Failed to load chat info"); if (!cancelled) setError("Failed to load chat info");
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) {
setLoading(false);
setAttachmentsLoading(false);
}
} }
})(); })();
return () => { return () => {
@@ -120,7 +132,13 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
} }
return createPortal( 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()}> <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"> <div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold">Chat info</p> <p className="text-sm font-semibold">Chat info</p>
@@ -321,6 +339,65 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
</div> </div>
) : null} ) : 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 ? ( {chat.type === "private" && !chat.is_saved && chat.counterpart_user_id ? (
<button <button
className="mb-3 w-full rounded bg-slate-700 px-3 py-2 text-sm disabled:opacity-60" 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} ) : null}
</aside> </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>, </div>,
document.body document.body
); );
@@ -383,3 +488,28 @@ function formatLastSeen(value: string): string {
minute: "2-digit" 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 { attachFile, requestUploadUrl, sendMessageWithClientId, 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";
type RecordingState = "idle" | "recording" | "locked";
export function MessageComposer() { export function MessageComposer() {
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
@@ -16,17 +18,39 @@ export function MessageComposer() {
const replyToByChat = useChatStore((s) => s.replyToByChat); const replyToByChat = useChatStore((s) => s.replyToByChat);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage); const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState(""); const [text, setText] = useState("");
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 recordingStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]); 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 [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file"); const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
const [previewUrl, setPreviewUrl] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!activeChatId) { if (!activeChatId) {
@@ -46,9 +70,29 @@ export function MessageComposer() {
if (previewUrl) { if (previewUrl) {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
if (pointerMoveHandlerRef.current) {
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
}
if (pointerUpHandlerRef.current) {
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
}
}; };
}, [previewUrl]); }, [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 { function makeClientMessageId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) { if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -147,15 +191,9 @@ export function MessageComposer() {
} }
function inferType(file: File): "file" | "image" | "video" | "audio" { function inferType(file: File): "file" | "image" | "video" | "audio" {
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/")) return "image";
return "image"; if (file.type.startsWith("video/")) return "video";
} if (file.type.startsWith("audio/")) return "audio";
if (file.type.startsWith("video/")) {
return "video";
}
if (file.type.startsWith("audio/")) {
return "audio";
}
return "file"; return "file";
} }
@@ -209,41 +247,136 @@ export function MessageComposer() {
} }
async function startRecord() { async function startRecord() {
if (recordingState !== "idle") {
return false;
}
try { try {
if (navigator.permissions && navigator.permissions.query) { if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName }); const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
if (permission.state === "denied") { if (permission.state === "denied") {
setUploadError("Microphone access denied. Allow microphone in browser site permissions."); setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
return; return false;
} }
} }
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);
recordingStreamRef.current = stream;
chunksRef.current = []; chunksRef.current = [];
recorder.ondataavailable = (e) => chunksRef.current.push(e.data); sendVoiceOnStopRef.current = true;
recorder.ondataavailable = (event) => chunksRef.current.push(event.data);
recorder.onstop = async () => { 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" }); 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); recordingStartedAtRef.current = Date.now();
setRecordSeconds(0);
setRecordingState("recording");
return true;
} catch { } catch {
setUploadError("Microphone access denied. Please allow microphone and retry."); setUploadError("Microphone access denied. Please allow microphone and retry.");
return false;
} }
} }
function stopRecord() { function stopRecord(send: boolean) {
recorderRef.current?.stop(); sendVoiceOnStopRef.current = send;
pointerCancelArmedRef.current = false;
setDragHint("idle");
if (recorderRef.current && recorderRef.current.state !== "inactive") {
recorderRef.current.stop();
}
recorderRef.current = null; 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) { function selectFile(file: File) {
setUploadError(null); setUploadError(null);
setSelectedFile(file); setSelectedFile(file);
setShowAttachMenu(false);
const fileType = inferType(file); const fileType = inferType(file);
setSelectedType(fileType); setSelectedType(fileType);
if (previewUrl) { if (previewUrl) {
@@ -262,11 +395,23 @@ export function MessageComposer() {
} }
const uploadFile = await prepareFileForUpload(selectedFile, selectedType); const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
await handleUpload(uploadFile, 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) { if (previewUrl) {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
setSelectedFile(null); setSelectedFile(null);
setPreviewUrl(null); setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file"); setSelectedType("file");
setUploadProgress(0); setUploadProgress(0);
} }
@@ -277,18 +422,15 @@ export function MessageComposer() {
} }
setSelectedFile(null); setSelectedFile(null);
setPreviewUrl(null); setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file"); setSelectedType("file");
setUploadProgress(0); setUploadProgress(0);
setUploadError(null); setUploadError(null);
} }
function formatBytes(size: number): string { function formatBytes(size: number): string {
if (size < 1024) { if (size < 1024) return `${size} B`;
return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`;
} }
@@ -305,86 +447,186 @@ export function MessageComposer() {
</button> </button>
</div> </div>
) : null} ) : 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 <input
ref={mediaInputRef}
className="hidden" className="hidden"
type="file" type="file"
disabled={isUploading} accept="image/*,video/*"
onChange={(e) => { disabled={isUploading || recordingState !== "idle"}
const file = e.target.files?.[0]; onChange={(event) => {
const file = event.target.files?.[0];
if (file) { if (file) {
selectFile(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 <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..." placeholder="Write a message..."
rows={1} rows={1}
value={text} value={text}
onKeyDown={onComposerKeyDown} onKeyDown={onComposerKeyDown}
onChange={(e) => { onChange={(event) => {
const next = e.target.value; const next = event.target.value;
setText(next); setText(next);
if (activeChatId) { if (activeChatId) {
setDraft(activeChatId, next); setDraft(activeChatId, next);
}
if (activeChatId) {
const ws = getWs(); const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } })); 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 Send
</button> </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> </div>
{selectedFile ? ( {selectedFile ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm"> <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 font-semibold">Ready to send</div> <div className="mb-2 flex items-center justify-between">
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</div> <div>
<div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</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" ? ( {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} ) : null}
{previewUrl && selectedType === "video" ? ( {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} ) : 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 ? ( {isUploading ? (
<div className="mb-2"> <div className="mb-2">
<div className="mb-1 text-xs text-slate-300">Uploading: {uploadProgress}%</div> <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-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>
</div> </div>
) : null} ) : null}
<div className="flex gap-2"> <div className="flex gap-2">
<button <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()} onClick={() => void sendSelectedFile()}
disabled={isUploading} disabled={isUploading}
> >
Send media Send
</button> </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 Cancel
</button> </button>
</div> </div>
</div> </div>
) : null} ) : null}
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</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> </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 { 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 type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
@@ -13,6 +19,12 @@ type ContextMenuState = {
messageId: number; messageId: number;
} | null; } | null;
type AttachmentMenuState = {
x: number;
y: number;
url: string;
} | null;
type PendingDeleteState = { type PendingDeleteState = {
chatId: number; chatId: number;
messages: Message[]; messages: Message[];
@@ -20,6 +32,8 @@ type PendingDeleteState = {
timerId: number; timerId: number;
} | null; } | null;
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
export function MessageList() { export function MessageList() {
const me = useAuthStore((s) => s.me); const me = useAuthStore((s) => s.me);
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
@@ -36,7 +50,9 @@ export function MessageList() {
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage); const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages); const restoreMessages = useChatStore((s) => s.restoreMessages);
const [ctx, setCtx] = useState<ContextMenuState>(null); const [ctx, setCtx] = useState<ContextMenuState>(null);
const [attachmentCtx, setAttachmentCtx] = useState<AttachmentMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null); const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
const [forwardQuery, setForwardQuery] = useState(""); const [forwardQuery, setForwardQuery] = useState("");
const [forwardError, setForwardError] = useState<string | null>(null); const [forwardError, setForwardError] = useState<string | null>(null);
@@ -72,10 +88,7 @@ export function MessageList() {
const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null; const focusedMessageId = activeChatId ? (focusedMessageIdByChat[activeChatId] ?? null) : null;
const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]); const hasMore = Boolean(activeChatId && hasMoreByChat[activeChatId]);
const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]); const isLoadingMore = Boolean(activeChatId && loadingMoreByChat[activeChatId]);
const selectedMessages = useMemo( const selectedMessages = useMemo(() => messages.filter((m) => selectedIds.has(m.id)), [messages, selectedIds]);
() => messages.filter((m) => selectedIds.has(m.id)),
[messages, selectedIds]
);
const canDeleteAllForSelection = useMemo( const canDeleteAllForSelection = useMemo(
() => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)), () => selectedMessages.length > 0 && selectedMessages.every((m) => canDeleteForEveryone(m, activeChat, me?.id)),
[selectedMessages, activeChat, me?.id] [selectedMessages, activeChat, me?.id]
@@ -87,6 +100,7 @@ export function MessageList() {
return; return;
} }
setCtx(null); setCtx(null);
setAttachmentCtx(null);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set()); setForwardSelectedChatIds(new Set());
setDeleteMessageId(null); setDeleteMessageId(null);
@@ -99,6 +113,7 @@ export function MessageList() {
useEffect(() => { useEffect(() => {
setSelectedIds(new Set()); setSelectedIds(new Set());
setCtx(null); setCtx(null);
setAttachmentCtx(null);
setDeleteMessageId(null); setDeleteMessageId(null);
setForwardMessageId(null); setForwardMessageId(null);
setForwardSelectedChatIds(new Set()); setForwardSelectedChatIds(new Set());
@@ -131,6 +146,27 @@ export function MessageList() {
} }
const chatId = activeChatId; 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() { async function handleForwardSubmit() {
if (!forwardMessageId) return; if (!forwardMessageId) return;
const targetChatIds = [...forwardSelectedChatIds]; 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) { function toggleSelected(messageId: number) {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -283,7 +298,7 @@ export function MessageList() {
} }
return ( 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 ? ( {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"> <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"} 📌 {messagesMap.get(activeChat.pinned_message_id)?.text || "Pinned message"}
@@ -307,7 +322,8 @@ export function MessageList() {
</div> </div>
</div> </div>
) : null} ) : 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 ? ( {hasMore ? (
<div className="mb-3 flex justify-center"> <div className="mb-3 flex justify-center">
<button <button
@@ -319,10 +335,13 @@ export function MessageList() {
</button> </button>
</div> </div>
) : null} ) : null}
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id; const own = message.sender_id === me?.id;
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null; const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id); const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
return ( return (
<div key={`${message.id}-${message.client_message_id ?? ""}`}> <div key={`${message.id}-${message.client_message_id ?? ""}`}>
{unreadBoundaryIndex === messageIndex ? ( {unreadBoundaryIndex === messageIndex ? (
@@ -334,143 +353,222 @@ export function MessageList() {
<span className="h-px flex-1 bg-slate-700/60" /> <span className="h-px flex-1 bg-slate-700/60" />
</div> </div>
) : null} ) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}> <div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div <div
id={`message-${message.id}`} id={`message-${message.id}`}
className={`max-w-[86%] rounded-2xl px-3 py-2 shadow-sm md:max-w-[72%] ${ className={`max-w-[90%] rounded-2xl px-3 py-2.5 shadow-sm md:max-w-[70%] ${
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" own
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`} ? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950"
onClick={() => { : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
if (selectedIds.size > 0) { } ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
toggleSelected(message.id); onClick={() => {
} if (selectedIds.size > 0) {
}} toggleSelected(message.id);
onContextMenu={(e) => { }
e.preventDefault(); }}
void ensureReactionsLoaded(message.id); onContextMenu={(event) => {
const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); event.preventDefault();
setCtx({ x: pos.x, y: pos.y, messageId: message.id }); 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 ? "✓" : ""} {selectedIds.size > 0 ? (
</div> <div
) : null} className={`mb-1 inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px] ${
{message.forwarded_from_message_id ? ( isSelected ? "border-sky-300 bg-sky-300 text-slate-900" : "border-slate-400 text-slate-300"
<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> {isSelected ? "✓" : ""}
) : null} </div>
{replySource ? ( ) : null}
<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> {message.forwarded_from_message_id ? (
<p className="truncate">{replySource.text || "[media]"}</p> <div
</div> className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
) : null} own
{renderContent(message.type, message.text)} ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
<div className="mt-1 flex flex-wrap gap-1"> : "border-sky-400 bg-slate-800/60 text-sky-300"
{["👍", "❤️", "🔥"].map((emoji) => { }`}
const items = reactionsByMessage[message.id] ?? []; >
const item = items.find((reaction) => reaction.emoji === emoji); Forwarded message
return ( </div>
<button ) : null}
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" {replySource ? (
}`} <div
key={`${message.id}-${emoji}`} className={`mb-1 rounded-md border-l-2 px-2 py-1 text-[11px] ${
onClick={() => void handleToggleReaction(message.id, emoji)} own
type="button" ? "border-slate-900/60 bg-slate-900/10 text-slate-900/75"
> : "border-sky-400 bg-slate-800/60 text-slate-300"
{emoji}{item ? ` ${item.count}` : ""} }`}
</button> >
); <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> </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>
); );
})} })}
</div> </div>
<div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div> <div className="px-5 pb-2 text-xs text-slate-300/80">{(typingByChat[activeChatId] ?? []).length > 0 ? "typing..." : ""}</div>
{ctx {ctx
? createPortal( ? createPortal(
<div <div
className="fixed z-[100] w-40 rounded-lg border border-slate-700/80 bg-slate-900/95 p-1 shadow-2xl" 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 }} style={{ left: ctx.x, top: ctx.y }}
onClick={(e) => e.stopPropagation()} onClick={(event) => event.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);
}}
> >
Reply <div className="mb-1 flex flex-wrap gap-1 rounded-lg bg-slate-800/80 p-1">
</button> {QUICK_REACTIONS.map((emoji) => (
<button <button
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" className="rounded-md px-1.5 py-1 text-sm hover:bg-slate-700"
onClick={() => { key={emoji}
setForwardMessageId(ctx.messageId); onClick={() => void handleToggleReaction(ctx.messageId, emoji)}
setForwardQuery(""); type="button"
setForwardError(null); >
setForwardSelectedChatIds(new Set()); {emoji}
setCtx(null); </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 <a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} rel="noreferrer" target="_blank">
</button> Open
<button </a>
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800" <a className="block rounded px-2 py-1.5 text-sm hover:bg-slate-800" href={attachmentCtx.url} download>
onClick={() => { Download
setSelectedIds(new Set([ctx.messageId])); </a>
setCtx(null); <button
}} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
> onClick={async () => {
Select try {
</button> await navigator.clipboard.writeText(attachmentCtx.url);
<button } catch {
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800" return;
onClick={() => { }
setDeleteMessageId(ctx.messageId); }}
setDeleteError(null); type="button"
setCtx(null); >
}} Copy link
> </button>
Delete </div>,
</button> document.body
<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} : null}
{forwardMessageId ? ( {forwardMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setForwardMessageId(null)}> <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> <p className="mb-2 text-sm font-semibold">Forward message</p>
<input <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" 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" placeholder="Search chats"
value={forwardQuery} 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"> <div className="tg-scrollbar max-h-56 space-y-1 overflow-auto">
{forwardTargets.map((chat) => ( {forwardTargets.map((chat) => (
<button <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} disabled={isForwarding}
key={chat.id} key={chat.id}
onClick={() => { onClick={() => {
@@ -506,7 +604,7 @@ export function MessageList() {
{deleteMessageId ? ( {deleteMessageId ? (
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}> <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-2 text-sm font-semibold">Delete message</p>
<p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p> <p className="mb-3 text-xs text-slate-400">Choose how to delete this message.</p>
<div className="space-y-2"> <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 (!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 === "image") {
if (messageType === "audio" || messageType === "voice") return <audio controls src={text} />;
if (messageType === "file") {
return ( return (
<a className="underline" href={text} rel="noreferrer" target="_blank"> <div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
Open file <img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
</a> </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) }} />; return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
} }
@@ -595,3 +745,14 @@ function canDeleteForEveryone(
if (chat.type === "private") return true; if (chat.type === "private") return true;
return message.sender_id === meId; 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";
}
}