diff --git a/backend/src/controllers/message-controller.ts b/backend/src/controllers/message-controller.ts index 5f984d8..08eceb3 100644 --- a/backend/src/controllers/message-controller.ts +++ b/backend/src/controllers/message-controller.ts @@ -51,4 +51,30 @@ export const getMessages = async (req: Request, res: Response) => { const messages = await MessageService.getMessages(channelId); res.json({ messages }); +} + +export const moveMessage = async (req: Request, res: Response) => { + const { messageId } = req.params; + const { targetChannelId } = req.body; + + if (!messageId || !targetChannelId) { + return res.status(400).json({ error: 'Message ID and target channel ID are required' }); + } + + try { + const result = await MessageService.moveMessage(messageId, targetChannelId); + logger.info(`Message ${messageId} moved to channel ${targetChannelId}`); + + res.json({ + message: 'Message moved successfully', + messageId: parseInt(messageId), + targetChannelId: parseInt(targetChannelId) + }); + } catch (error: any) { + if (error.message === 'Message not found') { + return res.status(404).json({ error: 'Message not found' }); + } + logger.critical(`Failed to move message ${messageId}:`, error); + res.status(500).json({ error: 'Failed to move message' }); + } } \ No newline at end of file diff --git a/backend/src/controllers/websocket-controller.ts b/backend/src/controllers/websocket-controller.ts index 56479cc..38cc65f 100644 --- a/backend/src/controllers/websocket-controller.ts +++ b/backend/src/controllers/websocket-controller.ts @@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => { events.on('message-deleted', (id) => { ws.send(JSON.stringify({ type: 'message-deleted', data: {id }})); }); + events.on('message-moved', (messageId, sourceChannelId, targetChannelId) => { + ws.send(JSON.stringify({ type: 'message-moved', data: {messageId, sourceChannelId, targetChannelId }})); + }); events.on('channel-created', (channel) => { ws.send(JSON.stringify({ type: 'channel-created', data: {channel }})); }); diff --git a/backend/src/routes/message.ts b/backend/src/routes/message.ts index b3d3f42..4fb5842 100644 --- a/backend/src/routes/message.ts +++ b/backend/src/routes/message.ts @@ -6,6 +6,7 @@ export const router = Router({mergeParams: true}); router.post('/', authenticate, MessageController.createMessage); router.put('/:messageId', authenticate, MessageController.updateMessage); +router.put('/:messageId/move', authenticate, MessageController.moveMessage); router.delete('/:messageId', authenticate, MessageController.deleteMessage); router.get('/', authenticate, MessageController.getMessages); diff --git a/backend/src/services/message-service.ts b/backend/src/services/message-service.ts index f27cf38..1041b0a 100644 --- a/backend/src/services/message-service.ts +++ b/backend/src/services/message-service.ts @@ -80,4 +80,28 @@ export const getMessage = async (id: string) => { `); const row = query.get({ id: id }); return row; +} + +export const moveMessage = async (messageId: string, targetChannelId: string) => { + // Get current message to emit proper events + const currentMessage = await getMessage(messageId); + if (!currentMessage) { + throw new Error('Message not found'); + } + + const query = db.prepare(`UPDATE messages SET channelId = $targetChannelId WHERE id = $messageId`); + const result = query.run({ messageId: messageId, targetChannelId: targetChannelId }); + + if (result.changes === 0) { + throw new Error('Message not found or not updated'); + } + + // Update FTS table if enabled + if (FTS5Enabled) { + // FTS table doesn't need channelId update, just content remains searchable + // No additional FTS changes needed since content hasn't changed + } + + events.emit('message-moved', messageId, (currentMessage as any).channelId, targetChannelId); + return result; } \ No newline at end of file diff --git a/frontend-vue/src/components/chat/MessageItem.vue b/frontend-vue/src/components/chat/MessageItem.vue index d01775d..f36fec6 100644 --- a/frontend-vue/src/components/chat/MessageItem.vue +++ b/frontend-vue/src/components/chat/MessageItem.vue @@ -10,6 +10,7 @@ :aria-label="messageAriaLabel" role="option" @keydown="handleKeydown" + @click="handleClick" >
{{ message.content }} @@ -50,6 +51,10 @@ interface Props { tabindex?: number } +const emit = defineEmits<{ + 'open-dialog': [message: ExtendedMessage | UnsentMessage] +}>() + const props = withDefaults(defineProps(), { isUnsent: false }) @@ -155,6 +160,13 @@ const getFileType = (filename: string): string => { } } +const handleClick = () => { + // Only open dialog for sent messages (not unsent ones) + if (!props.isUnsent) { + emit('open-dialog', props.message) + } +} + const handleKeydown = (event: KeyboardEvent) => { // Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.) if (event.ctrlKey || event.metaKey || event.altKey) { @@ -271,10 +283,14 @@ const handleDelete = async () => { border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s ease; } .message:hover { background: #f1f3f4; + border-color: #3b82f6; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1); } .message:focus { @@ -329,6 +345,8 @@ const handleDelete = async () => { .message:hover { background: #374151; + border-color: #60a5fa; + box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1); } .message__content { diff --git a/frontend-vue/src/components/chat/MessagesContainer.vue b/frontend-vue/src/components/chat/MessagesContainer.vue index 0bb21af..647b0cd 100644 --- a/frontend-vue/src/components/chat/MessagesContainer.vue +++ b/frontend-vue/src/components/chat/MessagesContainer.vue @@ -6,12 +6,14 @@ + @focus="focusedMessageIndex = index" + @open-dialog="emit('open-message-dialog', $event)" /> + :data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index" + @open-dialog="emit('open-message-dialog', $event)" />
@@ -28,6 +30,7 @@ interface Props { const emit = defineEmits<{ 'message-selected': [message: ExtendedMessage | UnsentMessage, index: number] + 'open-message-dialog': [message: ExtendedMessage | UnsentMessage] }>() const props = defineProps() @@ -165,9 +168,18 @@ onMounted(() => { } }) +const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => { + const messages = allMessages.value + if (focusedMessageIndex.value >= 0 && focusedMessageIndex.value < messages.length) { + return messages[focusedMessageIndex.value] + } + return null +} + defineExpose({ scrollToBottom, - focusMessageById + focusMessageById, + getFocusedMessage }) diff --git a/frontend-vue/src/components/dialogs/MessageDialog.vue b/frontend-vue/src/components/dialogs/MessageDialog.vue new file mode 100644 index 0000000..685649a --- /dev/null +++ b/frontend-vue/src/components/dialogs/MessageDialog.vue @@ -0,0 +1,720 @@ + + + + + \ No newline at end of file diff --git a/frontend-vue/src/composables/useWebSocket.ts b/frontend-vue/src/composables/useWebSocket.ts index 8f87d93..7a3fefd 100644 --- a/frontend-vue/src/composables/useWebSocket.ts +++ b/frontend-vue/src/composables/useWebSocket.ts @@ -64,6 +64,24 @@ export function useWebSocket() { appStore.removeMessage(parseInt(data.id)) } + const handleMessageMoved = (data: { messageId: string, sourceChannelId: string, targetChannelId: string }) => { + console.log('WebSocket: Message moved event received:', data) + const messageId = parseInt(data.messageId) + const sourceChannelId = parseInt(data.sourceChannelId) + const targetChannelId = parseInt(data.targetChannelId) + + appStore.moveMessage(messageId, sourceChannelId, targetChannelId) + + // Show toast notification if the move affects the current view + if (appStore.currentChannelId === sourceChannelId || appStore.currentChannelId === targetChannelId) { + const sourceChannel = appStore.channels.find(c => c.id === sourceChannelId) + const targetChannel = appStore.channels.find(c => c.id === targetChannelId) + if (sourceChannel && targetChannel) { + toastStore.info(`Message moved from "${sourceChannel.name}" to "${targetChannel.name}"`) + } + } + } + const handleFileUploaded = (data: any) => { // Handle file upload events with flattened format const messageUpdate: Partial = { @@ -127,6 +145,7 @@ export function useWebSocket() { websocketService.on('message-created', handleMessageCreated) websocketService.on('message-updated', handleMessageUpdated) websocketService.on('message-deleted', handleMessageDeleted) + websocketService.on('message-moved', handleMessageMoved) websocketService.on('file-uploaded', handleFileUploaded) websocketService.on('channel-created', handleChannelCreated) websocketService.on('channel-deleted', handleChannelDeleted) @@ -151,6 +170,7 @@ export function useWebSocket() { websocketService.off('message-created', handleMessageCreated) websocketService.off('message-updated', handleMessageUpdated) websocketService.off('message-deleted', handleMessageDeleted) + websocketService.off('message-moved', handleMessageMoved) websocketService.off('file-uploaded', handleFileUploaded) websocketService.off('channel-created', handleChannelCreated) websocketService.off('channel-deleted', handleChannelDeleted) diff --git a/frontend-vue/src/services/api.ts b/frontend-vue/src/services/api.ts index c110a3a..1b45f77 100644 --- a/frontend-vue/src/services/api.ts +++ b/frontend-vue/src/services/api.ts @@ -118,6 +118,13 @@ class ApiService { }) } + async moveMessage(channelId: number, messageId: number, targetChannelId: number): Promise<{ message: string, messageId: number, targetChannelId: number }> { + return this.request(`/channels/${channelId}/messages/${messageId}/move`, { + method: 'PUT', + body: JSON.stringify({ targetChannelId }) + }) + } + // Files async uploadFile(channelId: number, messageId: number, file: File): Promise { const formData = new FormData() diff --git a/frontend-vue/src/stores/app.ts b/frontend-vue/src/stores/app.ts index a4e6db9..e9c5463 100644 --- a/frontend-vue/src/stores/app.ts +++ b/frontend-vue/src/stores/app.ts @@ -112,6 +112,35 @@ export const useAppStore = defineStore('app', () => { } } + const moveMessage = (messageId: number, sourceChannelId: number, targetChannelId: number) => { + // Find and remove message from source channel + const sourceMessages = messages.value[sourceChannelId] || [] + const messageIndex = sourceMessages.findIndex(m => m.id === messageId) + + if (messageIndex === -1) { + console.warn(`Message ${messageId} not found in source channel ${sourceChannelId}`) + return + } + + const message = sourceMessages[messageIndex] + sourceMessages.splice(messageIndex, 1) + + // Update message's channel_id and add to target channel + const updatedMessage = { ...message, channel_id: targetChannelId } + + if (!messages.value[targetChannelId]) { + messages.value[targetChannelId] = [] + } + + const targetMessages = messages.value[targetChannelId] + targetMessages.push(updatedMessage) + + // Keep chronological order in target channel + targetMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + + console.log(`Message ${messageId} moved from channel ${sourceChannelId} to ${targetChannelId}`) + } + const addUnsentMessage = (message: UnsentMessage) => { unsentMessages.value.push(message) } @@ -182,6 +211,7 @@ export const useAppStore = defineStore('app', () => { addMessage, updateMessage, removeMessage, + moveMessage, addUnsentMessage, removeUnsentMessage, updateSettings, diff --git a/frontend-vue/src/views/MainView.vue b/frontend-vue/src/views/MainView.vue index d76d978..1eda41f 100644 --- a/frontend-vue/src/views/MainView.vue +++ b/frontend-vue/src/views/MainView.vue @@ -53,6 +53,7 @@ :messages="appStore.currentMessages" :unsent-messages="appStore.unsentMessagesForChannel" ref="messagesContainer" + @open-message-dialog="handleOpenMessageDialog" /> @@ -117,6 +118,18 @@ @close="showChannelInfoDialog = false" /> + + + + @@ -148,9 +161,10 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue' import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue' import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue' import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue' +import MessageDialog from '@/components/dialogs/MessageDialog.vue' // Types -import type { ExtendedMessage, Channel } from '@/types' +import type { ExtendedMessage, UnsentMessage, Channel } from '@/types' const router = useRouter() const appStore = useAppStore() @@ -178,7 +192,9 @@ const showSettings = ref(false) const showSearchDialog = ref(false) const showFileDialog = ref(false) const showVoiceDialog = ref(false) +const showMessageDialog = ref(false) const showCameraDialog = ref(false) +const selectedMessage = ref(null) // Mobile sidebar state const sidebarOpen = ref(false) @@ -280,6 +296,21 @@ const setupKeyboardShortcuts = () => { } }) + // Shift+Enter - Open message dialog for focused message + addShortcut({ + key: 'enter', + shiftKey: true, + handler: () => { + const focusedMessage = messagesContainer.value?.getFocusedMessage() + if (focusedMessage) { + handleOpenMessageDialog(focusedMessage) + toastStore.info('Opening message dialog') + } else { + toastStore.info('No message is focused') + } + } + }) + // Alt+Numbers - Announce last N messages for (let i = 1; i <= 9; i++) { addShortcut({ @@ -410,6 +441,93 @@ const scrollToBottom = () => { messagesContainer.value?.scrollToBottom() } +// Message dialog handlers +const handleOpenMessageDialog = (message: ExtendedMessage | UnsentMessage) => { + // Only allow dialog for sent messages (ExtendedMessage), not unsent ones + if ('created_at' in message) { + selectedMessage.value = message as ExtendedMessage + showMessageDialog.value = true + } +} + +const handleCloseMessageDialog = () => { + showMessageDialog.value = false + selectedMessage.value = null +} + +const handleEditMessage = async (messageId: number, content: string) => { + try { + if (!appStore.currentChannelId) return + + const response = await apiService.updateMessage(appStore.currentChannelId, messageId, content) + + // Update the message in the local store + const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId) + if (messageIndex !== -1) { + const updatedMessage = { ...appStore.currentMessages[messageIndex], content: content } + appStore.updateMessage(messageId, updatedMessage) + } + + // Update the selected message for the dialog + if (selectedMessage.value && selectedMessage.value.id === messageId) { + selectedMessage.value = { ...selectedMessage.value, content: content } + } + + toastStore.success('Message updated successfully') + handleCloseMessageDialog() + + } catch (error) { + console.error('Failed to edit message:', error) + toastStore.error('Failed to update message') + } +} + +const handleDeleteMessage = async (messageId: number) => { + try { + if (!appStore.currentChannelId) return + + await apiService.deleteMessage(appStore.currentChannelId, messageId) + + // Remove the message from the local store + const messageIndex = appStore.currentMessages.findIndex(m => m.id === messageId) + if (messageIndex !== -1) { + appStore.currentMessages.splice(messageIndex, 1) + } + + toastStore.success('Message deleted successfully') + handleCloseMessageDialog() + + } catch (error) { + console.error('Failed to delete message:', error) + toastStore.error('Failed to delete message') + } +} + +const handleMoveMessage = async (messageId: number, targetChannelId: number) => { + try { + if (!appStore.currentChannelId) return + + // Find the source channel for the message + let sourceChannelId = appStore.currentChannelId + const currentMessage = appStore.currentMessages.find(m => m.id === messageId) + if (currentMessage) { + sourceChannelId = currentMessage.channel_id + } + + await apiService.moveMessage(sourceChannelId, messageId, targetChannelId) + + // Optimistically update local state + appStore.moveMessage(messageId, sourceChannelId, targetChannelId) + + toastStore.success('Message moved successfully') + handleCloseMessageDialog() + + } catch (error) { + console.error('Failed to move message:', error) + toastStore.error('Failed to move message') + } +} + const handleChannelCreated = async (channelId: number) => { showChannelDialog.value = false await selectChannel(channelId)