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 @@
+
+
+
+
+
+
+
+
+
+ #{{ message.id }}
+
+
+
+
+
+
+
+
+
+ {{ channelName }}
+
+
+
+
+
+ {{ message.originalName }}
+ ({{ formatFileSize(message.fileSize || 0) }})
+
+
+
+
+
+
+
+
+
{{ message.content }}
+
+ Edit
+
+
+
+
+
+
+
+ Save
+
+
+ Cancel
+
+
+
+
+
+
+
+
File Actions
+
+
+ Download {{ message.originalName }}
+
+
+
+ View Image
+
+
+
+ {{ isPlaying ? 'Stop' : 'Play' }} Audio
+
+
+
+
+
+
+
Message Actions
+
+
+ Copy Content
+
+
+
+ Read Aloud
+
+
+
+ Delete Message
+
+
+
+ Move Message
+
+
+
+
+
+
+
+
+
Delete Message
+
Are you sure you want to delete this message? This cannot be undone.
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+
+
+
+
+
Move Message
+
Select the channel to move this message to:
+
+
+
+ Cancel
+
+
+ Move
+
+
+
+
+
+
+
+
+
+
\ 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)