392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
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 (
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
|
|
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
|
|
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
|
|
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
|
|
h1: ({ ...props }) => <h1 {...props} className="text-lg font-bold my-1" />,
|
|
h2: ({ ...props }) => <h2 {...props} className="text-base font-bold my-1" />,
|
|
h3: ({ ...props }) => <h3 {...props} className="text-sm font-bold my-0.5" />,
|
|
p: ({ ...props }) => <span {...props} className="inline" />,
|
|
img: ({ src, alt, ...props }) => (
|
|
<ExpandableImage src={src} alt={alt} {...props} />
|
|
),
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="group relative break-words" onContextMenu={handleContextMenu}>
|
|
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
|
|
<span className="text-gb-aqua"><{msg.author_username}></span>{" "}
|
|
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
|
|
|
|
{/* Message reactions */}
|
|
<ReactionBar
|
|
reactions={(msg.reactions || []).map((r: any) => ({
|
|
...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 && (
|
|
<KaomojiPicker
|
|
onSelect={(emoji) => onAddReaction(msg.id, emoji)}
|
|
onClose={() => setActiveReactionMessageId(null)}
|
|
/>
|
|
)}
|
|
{activeNativeReactionMessageId === msg.id && (
|
|
<div className="absolute z-50">
|
|
<Picker
|
|
theme={Theme.DARK}
|
|
onEmojiClick={(emoji) => {
|
|
onAddReaction(msg.id, emoji.emoji);
|
|
setActiveNativeReactionMessageId(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{MenuPortal}
|
|
</div>
|
|
);
|
|
});
|
|
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<string | null>(null);
|
|
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
|
|
const [activeNativeReactionMessageId, setActiveNativeReactionMessageId] = useState<string | null>(null);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const lastTypingRef = useRef<number>(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<HTMLTextAreaElement>) => {
|
|
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 (
|
|
<div className="flex flex-col h-full bg-gb-bg">
|
|
<div className="terminal-border border-t-0 border-x-0 px-2 md:px-3 py-2 text-gb-fg-s flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => useLayoutStore.getState().setMobileView('sidebar')}
|
|
className="md:hidden terminal-button text-xs px-2 py-0.5 shrink-0"
|
|
>
|
|
←
|
|
</button>
|
|
<span className="truncate">@ {title}</span>
|
|
</div>
|
|
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
|
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
|
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
|
|
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
|
|
{messages.map((msg: ConversationMessage, i: number) => {
|
|
const lastReadId = id ? convStates[id] : undefined;
|
|
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
|
return (
|
|
<>
|
|
<DMMessageItem
|
|
key={msg.id}
|
|
msg={msg}
|
|
onAddReaction={handleAddReaction}
|
|
activeReactionMessageId={activeReactionMessageId}
|
|
setActiveReactionMessageId={setActiveReactionMessageId}
|
|
activeNativeReactionMessageId={activeNativeReactionMessageId}
|
|
setActiveNativeReactionMessageId={setActiveNativeReactionMessageId}
|
|
currentUserId={currentUser?.id}
|
|
conversationId={id || ""}
|
|
/>
|
|
{showDivider && (
|
|
<div className="flex items-center gap-2 my-1">
|
|
<span className="flex-1 border-t border-gb-red"></span>
|
|
<span className="text-gb-red text-xs font-bold shrink-0">── NEW ──</span>
|
|
<span className="flex-1 border-t border-gb-red"></span>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
|
|
{(() => {
|
|
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...`;
|
|
})()}
|
|
</div>
|
|
{error && (
|
|
<div className="px-3 pb-1">
|
|
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
|
</div>
|
|
)}
|
|
<div className="p-3 relative">
|
|
<MessageInput
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(v) => {
|
|
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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|