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:
+81
-155
@@ -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 = ``;
|
||||
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)} />
|
||||
|
||||
Reference in New Issue
Block a user