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:
2026-07-06 17:33:20 +00:00
parent 9512dedec1
commit b3b5ff495d
19 changed files with 1369 additions and 301 deletions
+139 -108
View File
@@ -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">&lt;{msg.author_username}&gt;</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 = `![${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;
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>
);
}