import { useEffect, useRef, useState, useCallback, memo } from "react";
import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useTypingStore } from "../stores/typing.ts";
import { useLayoutStore } from "../stores/layout.ts";
import { useReadStatesStore } from "../stores/readStates.ts";
import { type Gif } from "./GiphyPicker.tsx";
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx";
import Picker, { Theme } from 'emoji-picker-react';
import { MessageInput } from "./MessageInput.tsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useContextMenu } from "./ContextMenu.tsx";
import { ReactionBar } from "./ReactionBar.tsx";
import { api } from "../lib/api.ts";
import { ExpandableImage } from "./ExpandableImage.tsx";
function formatTime(iso: string): string {
const date = new Date(iso);
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `[${day}.${month}.${year} @ ${hours}:${minutes}]`;
}
function renderDMContent(content: string) {
return (
,
code: ({ ...props }) => ,
pre: ({ ...props }) => ,
blockquote: ({ ...props }) => ,
h1: ({ ...props }) => ,
h2: ({ ...props }) => ,
h3: ({ ...props }) => ,
p: ({ ...props }) => ,
img: ({ src, alt, ...props }) => (
),
}}
>
{content}
);
}
const DMMessageItem = memo(({
msg,
onAddReaction,
activeReactionMessageId,
setActiveReactionMessageId,
activeNativeReactionMessageId,
setActiveNativeReactionMessageId,
currentUserId,
conversationId,
}: {
msg: ConversationMessage;
onAddReaction: (messageId: string, emoji: string) => void;
activeReactionMessageId: string | null;
setActiveReactionMessageId: (id: string | null) => void;
activeNativeReactionMessageId: string | null;
setActiveNativeReactionMessageId: (id: string | null) => void;
currentUserId?: string;
conversationId: string;
}) => {
const { showMenu, MenuPortal } = useContextMenu();
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const menuItems = [
{
label: "[ADD KAOMOJI REACTION]",
onClick: () => {
setActiveReactionMessageId(msg.id);
setActiveNativeReactionMessageId(null);
},
},
{
label: "[ADD EMOJI REACTION]",
onClick: () => {
setActiveNativeReactionMessageId(msg.id);
setActiveReactionMessageId(null);
},
},
{
label: "[DELETE MESSAGE]",
onClick: () => {
if (msg.author_id === currentUserId) {
api.delete(`/conversations/${conversationId}/messages/${msg.id}`).catch(console.error);
}
},
disabled: msg.author_id !== currentUserId,
danger: true,
},
];
showMenu(e, menuItems);
};
return (
[{formatTime(msg.created_at)}]{" "}
<{msg.author_username}>{" "}
{renderDMContent(msg.content)}
{/* Message reactions */}
({
...r,
reacted: r.users.includes(currentUserId),
}))}
onToggle={async (emoji, isReacted) => {
if (isReacted) {
await api.delete(`/conversations/${conversationId}/messages/${msg.id}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/conversations/${conversationId}/messages/${msg.id}/reactions`, { emoji });
}
}}
/>
{/* Kaomoji picker popover */}
{activeReactionMessageId === msg.id && (
onAddReaction(msg.id, emoji)}
onClose={() => setActiveReactionMessageId(null)}
/>
)}
{activeNativeReactionMessageId === msg.id && (
{
onAddReaction(msg.id, emoji.emoji);
setActiveNativeReactionMessageId(null);
}}
/>
)}
{MenuPortal}
);
});
DMMessageItem.displayName = "DMMessageItem";
export function DMChat() {
const { conversationId } = useParams<{ conversationId: string }>();
const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages);
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
const isLoading = useConversationStore((s) => s.isLoading);
const sendMessage = useConversationStore((s) => s.sendMessage);
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const currentUser = useAuthStore((s) => s.user);
const typingUsers = useTypingStore((s) => s.typingUsers);
const sendTypingStart = useTypingStore((s) => s.sendTypingStart);
const [input, setInput] = useState("");
const [error, setError] = useState(null);
const [activeReactionMessageId, setActiveReactionMessageId] = useState(null);
const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState(null);
const bottomRef = useRef(null);
const scrollContainerRef = useRef(null);
const inputRef = useRef(null);
const lastTypingRef = useRef(0);
const markConvRead = useReadStatesStore((s) => s.markConvRead);
const convStates = useReadStatesStore((s) => s.convStates);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id);
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
if (!id) return;
try {
await api.post(`/conversations/${id}/messages/${messageId}/reactions`, { emoji });
setActiveReactionMessageId(null);
setActiveNativeReactionMessageId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add reaction");
}
}, [id]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el || !id || isLoadingOlder) return;
if (el.scrollTop < 100) {
const prevHeight = el.scrollHeight;
fetchOlderMessages(id).then(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight - prevHeight;
});
});
}
}, [id, isLoadingOlder, fetchOlderMessages]);
const messages = id ? messagesByConv[id] || [] : [];
useEffect(() => {
fetchConversations();
}, [fetchConversations]);
useEffect(() => {
if (id) {
setActive(id);
fetchMessages(id);
setError(null);
}
}, [id, setActive, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
useEffect(() => {
if (!id || messages.length === 0 || isLoading) return;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.id) {
markConvRead(id, lastMsg.id);
}
}, [id, messages.length, isLoading]);
const handleGifSelect = async (gif: Gif) => {
if (!id) return;
const content = ``;
try {
await sendMessage(id, content);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
const handleSubmit = useCallback(() => {
const trimmed = input.trim();
if (!id || !trimmed) return;
setError(null);
sendMessage(id, trimmed).then(() => {
setInput("");
}).catch((err) => {
setError(err instanceof Error ? err.message : "Failed to send");
});
}, [id, input, sendMessage]);
const handlePaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
let imageFile: File | null = null;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
imageFile = items[i].getAsFile();
break;
}
}
if (imageFile) {
e.preventDefault();
const formData = new FormData();
formData.append('file', imageFile);
try {
const res = await fetch('/api/v1/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
const imageUrl = data.url;
// Insert into input
const imgMarkdown = ``;
const inputEl = inputRef.current;
if (inputEl) {
const start = inputEl.selectionStart || 0;
const end = inputEl.selectionEnd || 0;
const newValue = input.slice(0, start) + imgMarkdown + input.slice(end);
setInput(newValue);
setTimeout(() => {
inputEl.focus();
inputEl.setSelectionRange(start + imgMarkdown.length, start + imgMarkdown.length);
}, 0);
} else {
setInput(prev => prev + (prev.length > 0 && !prev.endsWith(' ') ? ' ' : '') + imgMarkdown);
}
} catch (err) {
console.error('Failed to upload image:', err);
}
}
};
const selfDM = conversation && conversation.members.length === 1 && conversation.members[0].id === currentUser?.id;
const title = selfDM
? "Notes"
: conversation
? conversation.type === "group_dm"
? conversation.name || conversation.members.map((m) => m.username).join(", ")
: conversation.members.find((m) => m.id !== currentUser?.id)?.username || "DM"
: id
? "[LOADING...]"
: "[NO CONVERSATION]";
return (
@ {title}
{isLoadingOlder &&
[loading older messages...]
}
{!id &&
[select a conversation]
}
{id && messages.length === 0 &&
[no messages]
}
{messages.map((msg: ConversationMessage, i: number) => {
const lastReadId = id ? convStates[id] : undefined;
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
return (
<>
{showDivider && (
── NEW ──
)}
>
);
})}
{(() => {
const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : [];
if (convTyping.length === 0) return "\u00A0";
const names = convTyping.map(u => u.username);
return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`;
})()}
{error && (
)}
{
setInput(v);
if (id && v.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(id);
lastTypingRef.current = now;
}
}
}}
onSubmit={handleSubmit}
onPaste={handlePaste}
onGifSelect={handleGifSelect}
disabled={!id}
/>
);
}