diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx index 2794723..6add601 100644 --- a/web/src/components/DMChat.tsx +++ b/web/src/components/DMChat.tsx @@ -149,6 +149,58 @@ export function DMChat() { } }; + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData.items; + let imageFile: File | null = null; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith('image/')) { + imageFile = items[i].getAsFile(); + break; + } + } + + if (imageFile) { + e.preventDefault(); + + const formData = new FormData(); + formData.append('file', imageFile); + + try { + const res = await fetch('/api/v1/upload', { + method: 'POST', + body: formData, + credentials: 'include' + }); + + if (!res.ok) throw new Error('Upload failed'); + + const data = await res.json(); + const imageUrl = data.url; + + // Insert into input + const imgMarkdown = `![image](${imageUrl})`; + + const inputEl = inputRef.current; + if (inputEl) { + const start = inputEl.selectionStart || 0; + const end = inputEl.selectionEnd || 0; + const newValue = input.slice(0, start) + imgMarkdown + input.slice(end); + setInput(newValue); + + setTimeout(() => { + inputEl.focus(); + inputEl.setSelectionRange(start + imgMarkdown.length, start + imgMarkdown.length); + }, 0); + } else { + setInput(prev => prev + (prev.length > 0 && !prev.endsWith(' ') ? ' ' : '') + imgMarkdown); + } + } catch (err) { + console.error('Failed to upload image:', err); + } + } + }; + const selfDM = conversation && conversation.members.length === 1 && conversation.members[0].id === currentUser?.id; const title = selfDM ? "Notes" @@ -215,6 +267,7 @@ export function DMChat() { } } }} + onPaste={handlePaste} placeholder="type a message..." className="terminal-input w-full" disabled={!id} diff --git a/web/src/stores/conversation.ts b/web/src/stores/conversation.ts index e9eb43b..6ee19e4 100644 --- a/web/src/stores/conversation.ts +++ b/web/src/stores/conversation.ts @@ -42,6 +42,8 @@ interface ConversationState { fetchOlderMessages: (conversationId: string) => Promise; sendMessage: (conversationId: string, content: string) => Promise; addMessage: (message: ConversationMessage) => void; + updateMessage: (message: ConversationMessage) => void; + deleteMessage: (conversationId: string, messageId: string) => void; } export const useConversationStore = create((set, get) => ({ @@ -151,8 +153,30 @@ export const useConversationStore = create((set, get) => ({ [message.conversation_id]: [ ...(state.messagesByConversation[message.conversation_id] || []), message, - ], + ].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()), }, })); }, + updateMessage: (message) => { + set((state) => { + const convMsgs = state.messagesByConversation[message.conversation_id] || []; + return { + messagesByConversation: { + ...state.messagesByConversation, + [message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)), + }, + }; + }); + }, + deleteMessage: (conversationId, messageId) => { + set((state) => { + const convMsgs = state.messagesByConversation[conversationId] || []; + return { + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: convMsgs.filter((m) => m.id !== messageId), + }, + }; + }); + }, })); diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index 03b7bbe..fcc0bbd 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -6,7 +6,9 @@ import { usePresenceStore } from './presence.ts'; import { useTypingStore } from './typing.ts'; import { useVoiceStore } from './voice.ts'; import { useAuthStore } from './auth.ts'; +import { useConversationStore } from './conversation.ts'; import type { Message } from './message.ts'; +import type { ConversationMessage } from './conversation.ts'; import type { Channel } from './channel.ts'; import type { Server } from './server.ts'; import type { UserStatus } from './auth.ts'; @@ -47,11 +49,11 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function extractIds(payload: UnknownPayload | undefined): { channel_id: string; message_id: string } | null { - if (!payload || typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') { - return null; - } - return { channel_id: payload.channel_id, message_id: payload.message_id }; +function extractIds(payload: UnknownPayload | undefined): { channel_id?: string; conversation_id?: string; message_id: string } | null { + if (!payload || typeof payload.message_id !== 'string') return null; + if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: payload.message_id }; + if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: payload.message_id }; + return null; } export const useWebSocketStore = create((set, get) => ({ @@ -127,42 +129,59 @@ export const useWebSocketStore = create((set, get) => ({ switch (data.type) { case 'MESSAGE_CREATE': { if (isRecord(payload)) { - const msg = payload as unknown as Message; - addMessage(msg); - - // Desktop notification - const currentUserId = useAuthStore.getState().user?.id; - if (msg.author_id !== currentUserId && !document.hasFocus()) { - // Update browser tab title - unreadCount++; - if (document.title.match(/^\(\d+\)\s/)) { - document.title = document.title.replace(/^\(\d+\)\s/, `(${unreadCount}) `); - } else { - document.title = `(${unreadCount}) ` + document.title; - } + if (payload.conversation_id) { + const msg = payload as unknown as ConversationMessage; + useConversationStore.getState().addMessage(msg); + } else { + const msg = payload as unknown as Message; + addMessage(msg); + + // Desktop notification + const currentUserId = useAuthStore.getState().user?.id; + if (msg.author_id !== currentUserId && !document.hasFocus()) { + // Update browser tab title + unreadCount++; + if (document.title.match(/^\(\d+\)\s/)) { + document.title = document.title.replace(/^\(\d+\)\s/, `(${unreadCount}) `); + } else { + document.title = `(${unreadCount}) ` + document.title; + } - if (Notification.permission === 'granted') { - const title = msg.author_username ? `New message from ${msg.author_display_name || msg.author_username}` : 'New message'; - const notification = new Notification(title, { - body: msg.content, - icon: '/icons/icon-192.png', - }); - notification.onclick = () => { - window.focus(); - notification.close(); - }; + if (Notification.permission === 'granted') { + const title = msg.author_username ? `New message from ${msg.author_display_name || msg.author_username}` : 'New message'; + const notification = new Notification(title, { + body: msg.content, + icon: '/icons/icon-192.png', + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + } } } } break; } case 'MESSAGE_UPDATE': { - if (isRecord(payload)) updateMessage(payload as unknown as Message); + if (isRecord(payload)) { + if (payload.conversation_id) { + useConversationStore.getState().updateMessage(payload as unknown as ConversationMessage); + } else { + updateMessage(payload as unknown as Message); + } + } break; } case 'MESSAGE_DELETE': { const ids = extractIds(payload); - if (ids) removeMessage(ids.channel_id, ids.message_id); + if (ids) { + if (ids.conversation_id) { + useConversationStore.getState().deleteMessage(ids.conversation_id, ids.message_id); + } else if (ids.channel_id) { + removeMessage(ids.channel_id, ids.message_id); + } + } break; } case 'REACTION_ADD': {