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
+68
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { api } from "../lib/api.ts";
import { type Reaction } from "./message.ts";
export interface ConversationMember {
id: string;
@@ -25,6 +26,7 @@ export interface ConversationMessage {
content: string;
created_at: string;
edited_at: string | null;
reactions?: Reaction[];
}
interface ConversationState {
@@ -44,6 +46,8 @@ interface ConversationState {
addMessage: (message: ConversationMessage) => void;
updateMessage: (message: ConversationMessage) => void;
deleteMessage: (conversationId: string, messageId: string) => void;
addReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
removeReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
}
export const useConversationStore = create<ConversationState>((set, get) => ({
@@ -106,6 +110,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
const existing = state.messagesByConversation[conversationId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
@@ -176,4 +181,67 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
};
});
},
addReaction: (conversationId, messageId, emoji, userId) => {
set((state) => {
const messages = state.messagesByConversation[conversationId];
if (!messages) return state;
const newMessages = messages.map((m) => {
if (m.id !== messageId) return m;
const reactions = [...(m.reactions || [])];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
if (!existing.users.includes(userId)) {
existing.users.push(userId);
existing.count++;
}
} else {
reactions.push({ emoji, count: 1, users: [userId] });
}
return { ...m, reactions };
});
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
},
};
});
},
removeReaction: (conversationId, messageId, emoji, userId) => {
set((state) => {
const messages = state.messagesByConversation[conversationId];
if (!messages) return state;
const newMessages = messages.map((m) => {
if (m.id !== messageId) return m;
let reactions = [...(m.reactions || [])];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
existing.users = existing.users.filter((id) => id !== userId);
existing.count--;
if (existing.count <= 0) {
reactions = reactions.filter((r) => r.emoji !== emoji);
}
}
return { ...m, reactions };
});
return {
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: newMessages,
},
};
});
},
}));
+1
View File
@@ -126,6 +126,7 @@ export const useMessageStore = create<MessageState>((set, get) => ({
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
const existing = state.messagesByChannel[channelId] || [];
if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id;
set({ isLoadingOlder: true });
try {
+4 -2
View File
@@ -103,9 +103,11 @@ export const useThreadStore = create<ThreadState>((set) => ({
set({ isLoading: true, error: null });
try {
const params = before ? `?before=${encodeURIComponent(before)}` : '';
const messages = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
const raw = await api.get<Message[]>(`/channels/${threadId}/messages${params}`);
// ponytail: API returns DESC (newest first), reverse to oldest-first
const msgs = (Array.isArray(raw) ? raw : []).reverse();
set((state) => ({
messagesByThread: { ...state.messagesByThread, [threadId]: Array.isArray(messages) ? messages : [] },
messagesByThread: { ...state.messagesByThread, [threadId]: msgs },
isLoading: false,
}));
} catch (error) {
+14 -4
View File
@@ -186,24 +186,34 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
case 'REACTION_ADD': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const convId = typeof payload.conversation_id === 'string' ? payload.conversation_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const reaction = isRecord(payload.reaction) ? payload.reaction : null;
if (channelId && messageId && reaction) {
if (messageId && reaction) {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.user_id === 'string' ? reaction.user_id : '';
if (emoji && userId) {
addReaction(channelId, messageId, emoji, userId);
if (convId) {
useConversationStore.getState().addReaction(convId, messageId, emoji, userId);
} else if (channelId) {
addReaction(channelId, messageId, emoji, userId);
}
}
}
break;
}
case 'REACTION_REMOVE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const convId = typeof payload.conversation_id === 'string' ? payload.conversation_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const emoji = typeof payload.emoji === 'string' ? payload.emoji : '';
const userId = typeof payload.user_id === 'string' ? payload.user_id : '';
if (channelId && messageId && emoji && userId) {
removeReaction(channelId, messageId, emoji, userId);
if (messageId && emoji && userId) {
if (convId) {
useConversationStore.getState().removeReaction(convId, messageId, emoji, userId);
} else if (channelId) {
removeReaction(channelId, messageId, emoji, userId);
}
}
break;
}