Files
dumpsterChat/web/src/components/DMChat.tsx
T
hobokenchicken fde0bfef37 feat: inline markdown formatting toolbar
FormatToolbar component: B I S ` || buttons that wrap selected text
with markdown syntax. Added to both channel and DM input areas.
Keyboard shortcuts: Ctrl+B bold, Ctrl+I italic already work via
browser defaults on the rendered markdown.
2026-07-02 15:58:59 -04:00

251 lines
9.5 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 { GiphyPicker, type Gif } from "./GiphyPicker.tsx";
import { EmojiPicker } from "./EmojiPicker.tsx";
import { FormatToolbar } from "./FormatToolbar.tsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
function formatTime(iso: string): string {
const date = new Date(iso);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${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" />,
p: ({ ...props }) => <span {...props} className="inline" />,
img: ({ src, ...props }) => {
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
: src;
return (
<img
{...props}
src={finalSrc}
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
loading="lazy"
/>
);
},
}}
>
{content}
</ReactMarkdown>
);
}
const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => {
return (
<div className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</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 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 [showGifPicker, setShowGifPicker] = useState(false);
const [showKaomoji, setShowKaomoji] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lastTypingRef = useRef<number>(0);
const id = conversationId || activeId;
const conversation = conversations.find((c) => c.id === 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]);
// Ctrl+E kaomoji shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
setShowKaomoji((prev) => !prev);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const handleGifSelect = async (gif: Gif) => {
if (!id) return;
const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`;
try {
await sendMessage(id, content);
setShowGifPicker(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id || !input.trim()) return;
setError(null);
try {
await sendMessage(id, input.trim());
setInput("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
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) => (
<DMMessageItem key={msg.id} msg={msg} />
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</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>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (id && e.target.value.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(id);
lastTypingRef.current = now;
}
}
}}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!id}
/>
<FormatToolbar inputRef={inputRef} setInput={setInput} disabled={!id} />
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!id}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
<button
type="button"
onClick={() => setShowKaomoji((prev) => !prev)}
disabled={!id}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle kaomoji picker (Ctrl+E)"
>
[]
</button>
{showKaomoji && (
<EmojiPicker
onSelect={(emoji) => setInput((prev) => prev + emoji)}
onClose={() => setShowKaomoji(false)}
/>
)}
</form>
</div>
);
}