fix: DM message ordering, consolidate input toolbar, add rich text/WYSIWYG, file upload with drag-drop
- 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)
This commit is contained in:
+139
-108
@@ -4,12 +4,16 @@ import { useConversationStore, type ConversationMessage } from "../stores/conver
|
||||
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 { type Gif } from "./GiphyPicker.tsx";
|
||||
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker.tsx";
|
||||
import Picker, { Theme } from 'emoji-picker-react';
|
||||
import { FormatToolbar } from "./FormatToolbar.tsx";
|
||||
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);
|
||||
@@ -28,19 +32,9 @@ function renderDMContent(content: string) {
|
||||
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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<ExpandableImage src={src} alt={alt} {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
@@ -48,12 +42,99 @@ function renderDMContent(content: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => {
|
||||
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="break-words">
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -75,17 +156,27 @@ export function DMChat() {
|
||||
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 [showNativeEmoji, setShowNativeEmoji] = useState(false);
|
||||
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<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const lastTypingRef = useRef<number>(0);
|
||||
|
||||
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;
|
||||
@@ -116,42 +207,28 @@ export function DMChat() {
|
||||
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;
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = input.trim();
|
||||
if (!id || !trimmed) return;
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(id, input.trim());
|
||||
sendMessage(id, trimmed).then(() => {
|
||||
setInput("");
|
||||
} catch (err) {
|
||||
}).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to send");
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [id, input, sendMessage]);
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData.items;
|
||||
let imageFile: File | null = null;
|
||||
|
||||
@@ -231,15 +308,20 @@ export function DMChat() {
|
||||
{!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} />
|
||||
<DMMessageItem
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onAddReaction={handleAddReaction}
|
||||
activeReactionMessageId={activeReactionMessageId}
|
||||
setActiveReactionMessageId={setActiveReactionMessageId}
|
||||
activeNativeReactionMessageId={activeNativeReactionMessageId}
|
||||
setActiveNativeReactionMessageId={setActiveNativeReactionMessageId}
|
||||
currentUserId={currentUser?.id}
|
||||
conversationId={id || ""}
|
||||
/>
|
||||
))}
|
||||
<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) : [];
|
||||
@@ -253,15 +335,13 @@ export function DMChat() {
|
||||
<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
|
||||
<div className="p-3 relative">
|
||||
<MessageInput
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
if (id && e.target.value.length > 0) {
|
||||
onChange={(v) => {
|
||||
setInput(v);
|
||||
if (id && v.length > 0) {
|
||||
const now = Date.now();
|
||||
if (now - lastTypingRef.current > 3000) {
|
||||
sendTypingStart(id);
|
||||
@@ -269,61 +349,12 @@ export function DMChat() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onPaste={handlePaste}
|
||||
placeholder="type a message..."
|
||||
className="terminal-input w-full"
|
||||
onGifSelect={handleGifSelect}
|
||||
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={() => {
|
||||
setShowNativeEmoji((prev) => !prev);
|
||||
setShowKaomoji(false);
|
||||
setShowGifPicker(false);
|
||||
}}
|
||||
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 native emoji picker"
|
||||
>
|
||||
[E]
|
||||
</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 && (
|
||||
<KaomojiPicker
|
||||
onSelect={(emoji) => setInput((prev) => prev + emoji)}
|
||||
onClose={() => setShowKaomoji(false)}
|
||||
/>
|
||||
)}
|
||||
{showNativeEmoji && (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||
<Picker
|
||||
theme={Theme.DARK}
|
||||
onEmojiClick={(emoji) => {
|
||||
setInput((prev) => prev + emoji.emoji);
|
||||
setShowNativeEmoji(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user