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:
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user