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 = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`; 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 = `![image](${imageUrl})`; 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 && (

ERR: {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} />
); }