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 d3756b5f47
commit cb4df31f43
19 changed files with 1369 additions and 301 deletions
+81 -155
View File
@@ -7,7 +7,9 @@ import { useTypingStore } from "../stores/typing.ts";
import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { type Gif } from "./GiphyPicker";
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker";
import Picker, { Theme } from 'emoji-picker-react';
import { CommandDropdown } from "./CommandDropdown";
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
@@ -20,10 +22,9 @@ import { useContextMenu } from "./ContextMenu.tsx";
import { PinnedMessages } from "./PinnedMessages.tsx";
import { FeatureRequestsPanel } from "./FeatureRequestsPanel.tsx";
import { ReactionBar } from "./ReactionBar.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 { ReplyBar } from "./ReplyBar.tsx";
import { ExpandableImage } from "./ExpandableImage.tsx";
import { useLayoutStore } from "../stores/layout.ts";
import { ForumView } from "./ForumView.tsx";
import { CalendarView } from "./CalendarView.tsx";
@@ -96,19 +97,13 @@ function renderContent(content: string, memberUsernames: Set<string>) {
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
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}
/>
),
}}
>
{trimmed}
@@ -230,7 +225,6 @@ const MessageItem = memo(({
{/* Message reactions */}
<ReactionBar
messageId={message.id}
reactions={(message.reactions || []).map((r: any) => {
const usernames = r.users.map((uid: string) => {
const m = members.find((member: any) => member.id === uid);
@@ -243,7 +237,13 @@ const MessageItem = memo(({
reacted: r.users.includes(currentUserId),
};
})}
onRefresh={() => {}}
onToggle={async (emoji, isReacted) => {
if (isReacted) {
await api.delete(`/messages/${message.id}/reactions/${encodeURIComponent(emoji)}`);
} else {
await api.post(`/messages/${message.id}/reactions`, { emoji });
}
}}
/>
{/* Kaomoji picker popover */}
@@ -288,9 +288,6 @@ export function ChatArea() {
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
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 [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null);
@@ -307,7 +304,7 @@ export function ChatArea() {
const [profileUserId, setProfileUserId] = 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 dropdownRef = useRef({ mentionQuery: null as string | null, commandQuery: null as string | null, dropdownIndex: 0 });
// ponytail: keep ref in sync with state so the stable keydown listener reads current values
@@ -354,8 +351,6 @@ export function ChatArea() {
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
try {
await api.post(`/messages/${messageId}/reactions`, { emoji });
setActiveReactionMessageId(null);
setActiveNativeReactionMessageId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add reaction");
}
@@ -464,7 +459,7 @@ export function ChatArea() {
return () => clearInterval(t);
}, [slowmodeRemaining]);
const handlePaste = async (e: React.ClipboardEvent<HTMLInputElement>) => {
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData.items;
let imageFile: File | null = null;
@@ -516,41 +511,6 @@ export function ChatArea() {
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setInput(value);
// ponytail: throttle typing indicator to once per 3s
if (activeChannelId && value.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(activeChannelId);
lastTypingRef.current = now;
}
}
const beforeCursor = value.slice(0, cursor);
// Slash command detection (only at start of input)
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
setCommandQuery(beforeCursor.slice(1));
setMentionQuery(null);
return;
}
setCommandQuery(null);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) {
setMentionQuery(null);
return;
}
const between = beforeCursor.slice(atIndex + 1);
if (between.includes(" ") || between.includes("\n")) {
setMentionQuery(null);
return;
}
setMentionQuery(between);
};
const handleMentionSelect = (username: string) => {
const inputEl = inputRef.current;
if (!inputEl) {
@@ -578,14 +538,9 @@ export function ChatArea() {
inputEl.setSelectionRange(pos, pos);
};
// ponytail: window listener matches existing pattern in codebase (CommandDropdown, modals)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
setShowKaomoji((prev) => !prev);
return;
}
if (e.ctrlKey && e.key === 'e') return; // handled by MessageInput
const { mentionQuery: mq, commandQuery: cq, dropdownIndex: di } = dropdownRef.current;
const isDropdownOpen = mq !== null || cq !== null;
@@ -632,6 +587,12 @@ export function ChatArea() {
setInput('');
return;
}
if (cmd.name === 'emoji') {
setInput('');
setCommandQuery(null);
setDropdownIndex(0);
return;
}
const inputEl = inputRef.current;
const curVal = inputEl?.value || '';
const args = curVal.startsWith('/' + cmd.name + ' ')
@@ -653,26 +614,27 @@ export function ChatArea() {
return () => window.removeEventListener('keydown', handler);
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
// ponytail: don't submit when a dropdown is handling keyboard input
const handleSubmit = useCallback(async () => {
if (mentionQuery !== null || commandQuery !== null) return;
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
let messageText = input.trim();
// Slash command transform
if (messageText.startsWith('/')) {
const spaceIdx = messageText.indexOf(' ');
const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx);
const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim();
// /poll opens the poll creation modal
if (cmdName === 'poll') {
setShowPollModal(true);
setInput('');
setCommandQuery(null);
return;
}
if (cmdName === 'emoji') {
setInput('');
setCommandQuery(null);
return;
}
const cmd = findCommand(cmdName);
if (cmd) {
messageText = cmd.transform(args, currentUser?.username || 'user');
@@ -695,14 +657,13 @@ export function ChatArea() {
}
setError(msg);
}
};
}, [mentionQuery, commandQuery, activeChannelId, input, slowmodeRemaining, sendMessage, replyToMessage, currentUser]);
const handleGifSelect = async (gif: Gif) => {
if (!activeChannelId) return;
const content = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`;
try {
await sendMessage(activeChannelId, content);
setShowGifPicker(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
@@ -887,11 +848,6 @@ export function ChatArea() {
))}
<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 chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
@@ -924,85 +880,55 @@ export function ChatArea() {
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
onPaste={handlePaste}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
<div className="p-3 relative">
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus();
}}
/>
)}
</div>
<FormatToolbar inputRef={inputRef} setInput={setInput} disabled={!activeChannelId} />
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
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);
)}
<MessageInput
ref={inputRef}
value={input}
onChange={(v) => {
setInput(v);
const cursor = inputRef.current?.selectionStart ?? v.length;
const beforeCursor = v.slice(0, cursor);
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
setCommandQuery(beforeCursor.slice(1));
setMentionQuery(null);
} else {
setCommandQuery(null);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1 || beforeCursor.slice(atIndex + 1).includes(" ") || beforeCursor.slice(atIndex + 1).includes("\n")) {
setMentionQuery(null);
} else {
setMentionQuery(beforeCursor.slice(atIndex + 1));
}
}
if (activeChannelId && v.length > 0) {
const now = Date.now();
if (now - lastTypingRef.current > 3000) {
sendTypingStart(activeChannelId);
lastTypingRef.current = now;
}
}
}}
onSubmit={handleSubmit}
onPaste={handlePaste}
onGifSelect={handleGifSelect}
disabled={!activeChannelId || slowmodeRemaining > 0}
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={!activeChannelId || slowmodeRemaining > 0}
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>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />