feat(web): improve message UX, voice gestures, and attachments
Some checks failed
CI / test (push) Failing after 21s
Some checks failed
CI / test (push) Failing after 21s
This commit is contained in:
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user