Message dialog and move individual message
This commit is contained in:
@@ -51,4 +51,30 @@ export const getMessages = async (req: Request, res: Response) => {
|
|||||||
const messages = await MessageService.getMessages(channelId);
|
const messages = await MessageService.getMessages(channelId);
|
||||||
|
|
||||||
res.json({ messages });
|
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' });
|
||||||
|
}
|
||||||
}
|
}
|
@@ -14,6 +14,9 @@ export const attachEvents = (ws: WebSocket) => {
|
|||||||
events.on('message-deleted', (id) => {
|
events.on('message-deleted', (id) => {
|
||||||
ws.send(JSON.stringify({ type: 'message-deleted', data: {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) => {
|
events.on('channel-created', (channel) => {
|
||||||
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
|
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
|
||||||
});
|
});
|
||||||
|
@@ -6,6 +6,7 @@ export const router = Router({mergeParams: true});
|
|||||||
|
|
||||||
router.post('/', authenticate, MessageController.createMessage);
|
router.post('/', authenticate, MessageController.createMessage);
|
||||||
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
router.put('/:messageId', authenticate, MessageController.updateMessage);
|
||||||
|
router.put('/:messageId/move', authenticate, MessageController.moveMessage);
|
||||||
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
|
||||||
router.get('/', authenticate, MessageController.getMessages);
|
router.get('/', authenticate, MessageController.getMessages);
|
||||||
|
|
||||||
|
@@ -80,4 +80,28 @@ export const getMessage = async (id: string) => {
|
|||||||
`);
|
`);
|
||||||
const row = query.get({ id: id });
|
const row = query.get({ id: id });
|
||||||
return row;
|
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;
|
||||||
}
|
}
|
@@ -10,6 +10,7 @@
|
|||||||
:aria-label="messageAriaLabel"
|
:aria-label="messageAriaLabel"
|
||||||
role="option"
|
role="option"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div class="message__content">
|
<div class="message__content">
|
||||||
{{ message.content }}
|
{{ message.content }}
|
||||||
@@ -50,6 +51,10 @@ interface Props {
|
|||||||
tabindex?: number
|
tabindex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
}>()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isUnsent: false
|
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) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
// Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.)
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
@@ -271,10 +283,14 @@ const handleDelete = async () => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:hover {
|
.message:hover {
|
||||||
background: #f1f3f4;
|
background: #f1f3f4;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:focus {
|
.message:focus {
|
||||||
@@ -329,6 +345,8 @@ const handleDelete = async () => {
|
|||||||
|
|
||||||
.message:hover {
|
.message:hover {
|
||||||
background: #374151;
|
background: #374151;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message__content {
|
.message__content {
|
||||||
|
@@ -6,12 +6,14 @@
|
|||||||
<MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
|
<MessageItem v-for="(message, index) in messages" :key="message.id" :message="message"
|
||||||
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
|
:tabindex="index === focusedMessageIndex ? 0 : -1" :data-message-index="index"
|
||||||
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
|
||||||
@focus="focusedMessageIndex = index" />
|
@focus="focusedMessageIndex = index"
|
||||||
|
@open-dialog="emit('open-message-dialog', $event)" />
|
||||||
|
|
||||||
<!-- Unsent Messages -->
|
<!-- Unsent Messages -->
|
||||||
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
|
||||||
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
:is-unsent="true" :tabindex="(messages.length + index) === focusedMessageIndex ? 0 : -1"
|
||||||
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index" />
|
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
|
||||||
|
@open-dialog="emit('open-message-dialog', $event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -28,6 +30,7 @@ interface Props {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||||
|
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -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({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
focusMessageById
|
focusMessageById,
|
||||||
|
getFocusedMessage
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
720
frontend-vue/src/components/dialogs/MessageDialog.vue
Normal file
720
frontend-vue/src/components/dialogs/MessageDialog.vue
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2>Message Details</h2>
|
||||||
|
<button class="close-button" @click="$emit('close')" aria-label="Close dialog">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-content">
|
||||||
|
<!-- Message Info Section -->
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Message ID</label>
|
||||||
|
<span>#{{ message.id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Sent</label>
|
||||||
|
<time :datetime="message.created_at">
|
||||||
|
{{ formatTimestampForScreenReader(message.created_at) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<label>Channel</label>
|
||||||
|
<span>{{ channelName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasFileAttachment" class="info-item">
|
||||||
|
<label>Attachment</label>
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">{{ message.originalName }}</span>
|
||||||
|
<span class="file-size">({{ formatFileSize(message.fileSize || 0) }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="content-section">
|
||||||
|
<label for="message-content">Message Content</label>
|
||||||
|
<div v-if="!isEditing" class="content-display">
|
||||||
|
<p>{{ message.content }}</p>
|
||||||
|
<BaseButton
|
||||||
|
@click="startEditing"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="edit-button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="content-edit">
|
||||||
|
<BaseTextarea
|
||||||
|
id="message-content"
|
||||||
|
v-model="editedContent"
|
||||||
|
placeholder="Message content..."
|
||||||
|
:rows="4"
|
||||||
|
auto-resize
|
||||||
|
ref="contentTextarea"
|
||||||
|
/>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<BaseButton @click="saveEdit" :disabled="!canSave" :loading="isSaving">
|
||||||
|
Save
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton @click="cancelEdit" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Actions Section (if file attachment exists) -->
|
||||||
|
<div v-if="hasFileAttachment" class="file-actions-section">
|
||||||
|
<h3>File Actions</h3>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<BaseButton @click="downloadFile" variant="secondary">
|
||||||
|
Download {{ message.originalName }}
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
v-if="isImageFile"
|
||||||
|
@click="viewImage"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
View Image
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
v-if="isAudioFile"
|
||||||
|
@click="playAudio"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{{ isPlaying ? 'Stop' : 'Play' }} Audio
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Actions Section -->
|
||||||
|
<div class="actions-section">
|
||||||
|
<h3>Message Actions</h3>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<BaseButton @click="copyMessage" variant="secondary">
|
||||||
|
Copy Content
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
@click="readAloud"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="!ttsEnabled"
|
||||||
|
>
|
||||||
|
Read Aloud
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
@click="showDeleteConfirm = true"
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Delete Message
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
@click="showMoveDialog = true"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Move Message
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<div v-if="showDeleteConfirm" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Delete Message</h3>
|
||||||
|
<p>Are you sure you want to delete this message? This cannot be undone.</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
@click="showDeleteConfirm = false"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
@click="deleteMessage"
|
||||||
|
variant="danger"
|
||||||
|
:loading="isDeleting"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Move Message Dialog -->
|
||||||
|
<div v-if="showMoveDialog" class="confirm-overlay">
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<h3>Move Message</h3>
|
||||||
|
<p>Select the channel to move this message to:</p>
|
||||||
|
<select v-model="selectedTargetChannelId" class="channel-select">
|
||||||
|
<option value="">Select a channel...</option>
|
||||||
|
<option
|
||||||
|
v-for="channel in availableChannels"
|
||||||
|
:key="channel.id"
|
||||||
|
:value="channel.id"
|
||||||
|
>
|
||||||
|
{{ channel.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<BaseButton
|
||||||
|
@click="showMoveDialog = false"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
@click="moveMessage"
|
||||||
|
variant="primary"
|
||||||
|
:loading="isMoving"
|
||||||
|
:disabled="!selectedTargetChannelId || selectedTargetChannelId === message.channel_id"
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { useAudio } from '@/composables/useAudio'
|
||||||
|
import { formatTimestampForScreenReader } from '@/utils/time'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||||
|
import type { ExtendedMessage } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: ExtendedMessage
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
edit: [messageId: number, content: string]
|
||||||
|
delete: [messageId: number]
|
||||||
|
move: [messageId: number, targetChannelId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
const { speak, playSound } = useAudio()
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editedContent = ref('')
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const showMoveDialog = ref(false)
|
||||||
|
const selectedTargetChannelId = ref<number | ''>('')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
const isMoving = ref(false)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const contentTextarea = ref()
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const channelName = computed(() => {
|
||||||
|
const channel = appStore.channels.find(c => c.id === props.message.channel_id)
|
||||||
|
return channel?.name || `Channel ${props.message.channel_id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasFileAttachment = computed(() => {
|
||||||
|
return !!(props.message.fileId && props.message.originalName)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImageFile = computed(() => {
|
||||||
|
if (!props.message.originalName) return false
|
||||||
|
const ext = props.message.originalName.split('.').pop()?.toLowerCase()
|
||||||
|
return ext && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isAudioFile = computed(() => {
|
||||||
|
if (!props.message.originalName) return false
|
||||||
|
const ext = props.message.originalName.split('.').pop()?.toLowerCase()
|
||||||
|
return ext && ['mp3', 'wav', 'webm', 'ogg', 'aac', 'm4a'].includes(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSave = computed(() => {
|
||||||
|
return editedContent.value.trim().length > 0 &&
|
||||||
|
editedContent.value.trim() !== props.message.content
|
||||||
|
})
|
||||||
|
|
||||||
|
const ttsEnabled = computed(() => appStore.settings.ttsEnabled)
|
||||||
|
|
||||||
|
const availableChannels = computed(() =>
|
||||||
|
appStore.channels.filter(channel => channel.id !== props.message.channel_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const startEditing = async () => {
|
||||||
|
isEditing.value = true
|
||||||
|
editedContent.value = props.message.content
|
||||||
|
await nextTick()
|
||||||
|
contentTextarea.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
editedContent.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!canSave.value) return
|
||||||
|
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
emit('edit', props.message.id, editedContent.value.trim())
|
||||||
|
isEditing.value = false
|
||||||
|
toastStore.success('Message updated successfully')
|
||||||
|
} catch (error) {
|
||||||
|
toastStore.error('Failed to update message')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMessage = async () => {
|
||||||
|
isDeleting.value = true
|
||||||
|
try {
|
||||||
|
emit('delete', props.message.id)
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
toastStore.success('Message deleted successfully')
|
||||||
|
} catch (error) {
|
||||||
|
toastStore.error('Failed to delete message')
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveMessage = async () => {
|
||||||
|
if (!selectedTargetChannelId.value || selectedTargetChannelId.value === props.message.channel_id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMoving.value = true
|
||||||
|
try {
|
||||||
|
emit('move', props.message.id, selectedTargetChannelId.value as number)
|
||||||
|
showMoveDialog.value = false
|
||||||
|
selectedTargetChannelId.value = ''
|
||||||
|
toastStore.success('Message moved successfully')
|
||||||
|
} catch (error) {
|
||||||
|
toastStore.error('Failed to move message')
|
||||||
|
} finally {
|
||||||
|
isMoving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyMessage = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.message.content)
|
||||||
|
playSound('copy')
|
||||||
|
toastStore.success('Message copied to clipboard')
|
||||||
|
} catch (error) {
|
||||||
|
toastStore.error('Failed to copy message')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readAloud = async () => {
|
||||||
|
if (appStore.settings.ttsEnabled) {
|
||||||
|
try {
|
||||||
|
await speak(props.message.content)
|
||||||
|
toastStore.info('Reading message aloud')
|
||||||
|
} catch (error) {
|
||||||
|
toastStore.error('Failed to read message aloud')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toastStore.info('Text-to-speech is disabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = () => {
|
||||||
|
if (props.message.filePath) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = `/api/files/${props.message.filePath}`
|
||||||
|
link.download = props.message.originalName || 'download'
|
||||||
|
link.click()
|
||||||
|
toastStore.success('Download started')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewImage = () => {
|
||||||
|
if (props.message.filePath) {
|
||||||
|
window.open(`/api/files/${props.message.filePath}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playAudio = () => {
|
||||||
|
if (props.message.filePath) {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
// Stop audio (would need audio instance management)
|
||||||
|
isPlaying.value = false
|
||||||
|
} else {
|
||||||
|
const audio = new Audio(`/api/files/${props.message.filePath}`)
|
||||||
|
audio.onended = () => { isPlaying.value = false }
|
||||||
|
audio.onerror = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
toastStore.error('Failed to play audio file')
|
||||||
|
}
|
||||||
|
audio.play()
|
||||||
|
isPlaying.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key to close dialog
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (isEditing.value) {
|
||||||
|
cancelEdit()
|
||||||
|
} else if (showDeleteConfirm.value) {
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
} else if (showMoveDialog.value) {
|
||||||
|
showMoveDialog.value = false
|
||||||
|
selectedTargetChannelId.value = ''
|
||||||
|
} else {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
const cleanup = () => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ cleanup })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-dialog {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span,
|
||||||
|
.info-item time {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section > label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-display {
|
||||||
|
position: relative;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-display p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #374151;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-edit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions-section,
|
||||||
|
.actions-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions-section h3,
|
||||||
|
.actions-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 1rem;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-select:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.message-dialog {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h2 {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item label {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span,
|
||||||
|
.info-item time {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section > label,
|
||||||
|
.file-actions-section h3,
|
||||||
|
.actions-section h3 {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-display {
|
||||||
|
background: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-display p {
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog p {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-select {
|
||||||
|
background: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-select:focus {
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message-dialog {
|
||||||
|
width: 95vw;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@@ -64,6 +64,24 @@ export function useWebSocket() {
|
|||||||
appStore.removeMessage(parseInt(data.id))
|
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) => {
|
const handleFileUploaded = (data: any) => {
|
||||||
// Handle file upload events with flattened format
|
// Handle file upload events with flattened format
|
||||||
const messageUpdate: Partial<ExtendedMessage> = {
|
const messageUpdate: Partial<ExtendedMessage> = {
|
||||||
@@ -127,6 +145,7 @@ export function useWebSocket() {
|
|||||||
websocketService.on('message-created', handleMessageCreated)
|
websocketService.on('message-created', handleMessageCreated)
|
||||||
websocketService.on('message-updated', handleMessageUpdated)
|
websocketService.on('message-updated', handleMessageUpdated)
|
||||||
websocketService.on('message-deleted', handleMessageDeleted)
|
websocketService.on('message-deleted', handleMessageDeleted)
|
||||||
|
websocketService.on('message-moved', handleMessageMoved)
|
||||||
websocketService.on('file-uploaded', handleFileUploaded)
|
websocketService.on('file-uploaded', handleFileUploaded)
|
||||||
websocketService.on('channel-created', handleChannelCreated)
|
websocketService.on('channel-created', handleChannelCreated)
|
||||||
websocketService.on('channel-deleted', handleChannelDeleted)
|
websocketService.on('channel-deleted', handleChannelDeleted)
|
||||||
@@ -151,6 +170,7 @@ export function useWebSocket() {
|
|||||||
websocketService.off('message-created', handleMessageCreated)
|
websocketService.off('message-created', handleMessageCreated)
|
||||||
websocketService.off('message-updated', handleMessageUpdated)
|
websocketService.off('message-updated', handleMessageUpdated)
|
||||||
websocketService.off('message-deleted', handleMessageDeleted)
|
websocketService.off('message-deleted', handleMessageDeleted)
|
||||||
|
websocketService.off('message-moved', handleMessageMoved)
|
||||||
websocketService.off('file-uploaded', handleFileUploaded)
|
websocketService.off('file-uploaded', handleFileUploaded)
|
||||||
websocketService.off('channel-created', handleChannelCreated)
|
websocketService.off('channel-created', handleChannelCreated)
|
||||||
websocketService.off('channel-deleted', handleChannelDeleted)
|
websocketService.off('channel-deleted', handleChannelDeleted)
|
||||||
|
@@ -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
|
// Files
|
||||||
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
|
async uploadFile(channelId: number, messageId: number, file: File): Promise<FileAttachment> {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
@@ -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) => {
|
const addUnsentMessage = (message: UnsentMessage) => {
|
||||||
unsentMessages.value.push(message)
|
unsentMessages.value.push(message)
|
||||||
}
|
}
|
||||||
@@ -182,6 +211,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
addMessage,
|
addMessage,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
|
moveMessage,
|
||||||
addUnsentMessage,
|
addUnsentMessage,
|
||||||
removeUnsentMessage,
|
removeUnsentMessage,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
@@ -53,6 +53,7 @@
|
|||||||
:messages="appStore.currentMessages"
|
:messages="appStore.currentMessages"
|
||||||
:unsent-messages="appStore.unsentMessagesForChannel"
|
:unsent-messages="appStore.unsentMessagesForChannel"
|
||||||
ref="messagesContainer"
|
ref="messagesContainer"
|
||||||
|
@open-message-dialog="handleOpenMessageDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Message Input -->
|
<!-- Message Input -->
|
||||||
@@ -117,6 +118,18 @@
|
|||||||
@close="showChannelInfoDialog = false"
|
@close="showChannelInfoDialog = false"
|
||||||
/>
|
/>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
|
<BaseDialog v-model:show="showMessageDialog" title="">
|
||||||
|
<MessageDialog
|
||||||
|
v-if="selectedMessage"
|
||||||
|
:message="selectedMessage"
|
||||||
|
:open="showMessageDialog"
|
||||||
|
@close="handleCloseMessageDialog"
|
||||||
|
@edit="handleEditMessage"
|
||||||
|
@delete="handleDeleteMessage"
|
||||||
|
@move="handleMoveMessage"
|
||||||
|
/>
|
||||||
|
</BaseDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -148,9 +161,10 @@ import FileUploadDialog from '@/components/dialogs/FileUploadDialog.vue'
|
|||||||
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
|
import VoiceRecordingDialog from '@/components/dialogs/VoiceRecordingDialog.vue'
|
||||||
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
import CameraCaptureDialog from '@/components/dialogs/CameraCaptureDialog.vue'
|
||||||
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
import ChannelInfoDialog from '@/components/dialogs/ChannelInfoDialog.vue'
|
||||||
|
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { ExtendedMessage, Channel } from '@/types'
|
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -178,7 +192,9 @@ const showSettings = ref(false)
|
|||||||
const showSearchDialog = ref(false)
|
const showSearchDialog = ref(false)
|
||||||
const showFileDialog = ref(false)
|
const showFileDialog = ref(false)
|
||||||
const showVoiceDialog = ref(false)
|
const showVoiceDialog = ref(false)
|
||||||
|
const showMessageDialog = ref(false)
|
||||||
const showCameraDialog = ref(false)
|
const showCameraDialog = ref(false)
|
||||||
|
const selectedMessage = ref<ExtendedMessage | null>(null)
|
||||||
|
|
||||||
// Mobile sidebar state
|
// Mobile sidebar state
|
||||||
const sidebarOpen = ref(false)
|
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
|
// Alt+Numbers - Announce last N messages
|
||||||
for (let i = 1; i <= 9; i++) {
|
for (let i = 1; i <= 9; i++) {
|
||||||
addShortcut({
|
addShortcut({
|
||||||
@@ -410,6 +441,93 @@ const scrollToBottom = () => {
|
|||||||
messagesContainer.value?.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) => {
|
const handleChannelCreated = async (channelId: number) => {
|
||||||
showChannelDialog.value = false
|
showChannelDialog.value = false
|
||||||
await selectChannel(channelId)
|
await selectChannel(channelId)
|
||||||
|
Reference in New Issue
Block a user