fde0bfef37
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.
251 lines
9.5 KiB
TypeScript
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"><{msg.author_username}></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 = ``;
|
|
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>
|
|
);
|
|
}
|