b3b5ff495d
- Fix DM backend ListMessages to use DESC + reverse (match channel handler) - Remove spurious .reverse() from frontend message/conversation stores - Create shared MessageInput component with Slack-style single toolbar row - Add file upload via + button with progress bar and drag-and-drop - Add markdown/rich text toggle with full WYSIWYG block formatting (lists, blockquotes, links, headings, code blocks) - Add frontend+backend security for file uploads (extension + content-type guards)
116 lines
4.3 KiB
TypeScript
116 lines
4.3 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useThreadStore } from '../stores/thread.ts';
|
|
import { useMessageStore } from '../stores/message.ts';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { ExpandableImage } from './ExpandableImage.tsx';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faPaperPlane } from '@fortawesome/free-solid-svg-icons';
|
|
|
|
interface ThreadPanelProps {
|
|
threadId: string;
|
|
threadName: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
const date = new Date(iso);
|
|
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
export function ThreadPanel({ threadId, threadName, onClose }: ThreadPanelProps) {
|
|
const messages = useThreadStore((s) => s.messagesByThread[threadId] || []);
|
|
const fetchMessages = useThreadStore((s) => s.fetchThreadMessages);
|
|
const sendMessage = useThreadStore((s) => s.sendThreadMessage);
|
|
const addMessage = useThreadStore((s) => s.addThreadMessage);
|
|
const removeMessage = useMessageStore((s) => s.removeMessage);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
fetchMessages(threadId);
|
|
}, [threadId, fetchMessages]);
|
|
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
}, [messages]);
|
|
|
|
// Listen for gateway messages scoped to this thread channel.
|
|
useEffect(() => {
|
|
const handler = (e: MessageEvent) => {
|
|
try {
|
|
const event = JSON.parse(e.data);
|
|
if (event.type === 'MESSAGE_CREATE' && event.data?.channel_id === threadId) {
|
|
addMessage(event.data);
|
|
}
|
|
if (event.type === 'MESSAGE_DELETE' && event.data?.channel_id === threadId) {
|
|
removeMessage(threadId, event.data.id);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
window.addEventListener('message', handler);
|
|
return () => window.removeEventListener('message', handler);
|
|
}, [threadId, addMessage, removeMessage]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const value = inputRef.current?.value.trim();
|
|
if (!value) return;
|
|
await sendMessage(threadId, value);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
};
|
|
|
|
return (
|
|
<div className="w-80 bg-gb-bg-s border-l border-gb-bg-t flex flex-col h-full">
|
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
|
<span>→ {threadName}</span>
|
|
<button onClick={onClose} className="text-gb-fg-f hover:text-gb-orange">[x]</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
|
{messages.map((m) => (
|
|
<div key={m.id} className="break-words">
|
|
<span className="text-gb-fg-f">[{formatTime(m.created_at)}]</span>{' '}
|
|
<span className="text-gb-aqua"><{m.author_username}></span>{' '}
|
|
<span className="text-gb-fg">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
p: ({ ...props }) => <span {...props} className="inline" />,
|
|
img: ({ src, alt, ...props }) => <ExpandableImage src={src} alt={alt} {...props} />,
|
|
}}
|
|
>
|
|
{m.content}
|
|
</ReactMarkdown>
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-3">
|
|
<div className="flex items-end gap-1 bg-gb-bg-s terminal-border px-2 py-1.5">
|
|
<textarea
|
|
ref={inputRef}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
(e.currentTarget.form as HTMLFormElement | null)?.requestSubmit();
|
|
}
|
|
}}
|
|
placeholder="Reply..."
|
|
rows={1}
|
|
className="flex-1 bg-transparent outline-none border-none resize-none overflow-y-auto max-h-20 text-sm py-1"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="text-gb-aqua hover:text-gb-orange p-1.5 shrink-0 disabled:opacity-40"
|
|
>
|
|
<FontAwesomeIcon icon={faPaperPlane} className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|