From 051c032f1de11bd1b88a39d52fb09987b9c47414 Mon Sep 17 00:00:00 2001 From: oriol Date: Fri, 9 Jan 2026 20:15:58 +0000 Subject: [PATCH] link opening with shift enter --- .../src/components/chat/InputActions.vue | 22 ++- .../src/components/chat/MessageInput.vue | 2 + .../src/components/chat/MessageItem.vue | 43 +++++- .../src/components/chat/MessagesContainer.vue | 41 ++++- .../dialogs/LinkSelectionDialog.vue | 144 ++++++++++++++++++ frontend-vue/src/views/MainView.vue | 44 ++++++ 6 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 frontend-vue/src/components/dialogs/LinkSelectionDialog.vue diff --git a/frontend-vue/src/components/chat/InputActions.vue b/frontend-vue/src/components/chat/InputActions.vue index 2c75cd1..b449228 100644 --- a/frontend-vue/src/components/chat/InputActions.vue +++ b/frontend-vue/src/components/chat/InputActions.vue @@ -39,7 +39,18 @@ > ✓ - + + + URL + + () @@ -82,9 +94,11 @@ defineEmits<{ flex-shrink: 0; } -/* Mobile-only for the checked toggle button */ -.input-actions [aria-label="Toggle check on focused message"] { display: none; } +/* Mobile-only for the checked toggle button and open URL button */ +.input-actions [aria-label="Toggle check on focused message"], +.input-actions .open-url-button { display: none; } @media (max-width: 480px) { - .input-actions [aria-label="Toggle check on focused message"] { display: inline-flex; } + .input-actions [aria-label="Toggle check on focused message"], + .input-actions .open-url-button { display: inline-flex; } } diff --git a/frontend-vue/src/components/chat/MessageInput.vue b/frontend-vue/src/components/chat/MessageInput.vue index adb4b09..34d8881 100644 --- a/frontend-vue/src/components/chat/MessageInput.vue +++ b/frontend-vue/src/components/chat/MessageInput.vue @@ -18,6 +18,7 @@ @camera="$emit('camera')" @voice="$emit('voice')" @toggle-check="$emit('toggle-check')" + @open-url="$emit('open-url')" @send="handleSubmit" /> @@ -37,6 +38,7 @@ const emit = defineEmits<{ 'camera': [] 'voice': [] 'toggle-check': [] + 'open-url': [] }>() const appStore = useAppStore() diff --git a/frontend-vue/src/components/chat/MessageItem.vue b/frontend-vue/src/components/chat/MessageItem.vue index 1573a3b..4cac733 100644 --- a/frontend-vue/src/components/chat/MessageItem.vue +++ b/frontend-vue/src/components/chat/MessageItem.vue @@ -67,6 +67,7 @@ interface Props { const emit = defineEmits<{ 'open-dialog': [message: ExtendedMessage | UnsentMessage] 'open-dialog-edit': [message: ExtendedMessage | UnsentMessage] + 'open-links': [links: string[], message: ExtendedMessage | UnsentMessage] 'focus': [] }>() @@ -195,7 +196,42 @@ const handleClick = () => { } } +// Extract URLs from text content +const extractUrls = (text: string): string[] => { + const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi + const matches = text.match(urlRegex) || [] + // Remove duplicates + return [...new Set(matches)] +} + +// Handle Shift+Enter: open URL(s) or fall back to edit +const handleOpenUrl = () => { + if (props.isUnsent) return + + const urls = extractUrls(props.message.content) + + if (urls.length === 0) { + // No links found, fall back to edit + emit('open-dialog-edit', props.message) + } else if (urls.length === 1) { + // Single link, open directly + window.open(urls[0], '_blank', 'noopener,noreferrer') + toastStore.success('Opening link') + } else { + // Multiple links, emit event for selection dialog + emit('open-links', urls, props.message) + } +} + const handleKeydown = (event: KeyboardEvent) => { + // Handle Shift+Enter for opening URLs + if (event.shiftKey && event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + handleOpenUrl() + return + } + // Don't interfere with normal keyboard shortcuts (Ctrl+C, Ctrl+V, etc.) if (event.ctrlKey || event.metaKey || event.altKey) { return @@ -206,7 +242,7 @@ const handleKeydown = (event: KeyboardEvent) => { toggleChecked() return } - + if (event.key === 'c') { // Copy message content (only when no modifiers are pressed) navigator.clipboard.writeText(props.message.content) @@ -341,6 +377,11 @@ const toggleChecked = async () => { } } +// Expose methods for external use (e.g., mobile button) +defineExpose({ + handleOpenUrl, + extractUrls +}) diff --git a/frontend-vue/src/views/MainView.vue b/frontend-vue/src/views/MainView.vue index f1d557a..93d9fd9 100644 --- a/frontend-vue/src/views/MainView.vue +++ b/frontend-vue/src/views/MainView.vue @@ -56,6 +56,7 @@ ref="messagesContainer" @open-message-dialog="handleOpenMessageDialog" @open-message-dialog-edit="handleOpenMessageDialogEdit" + @open-links="handleOpenLinks" /> @@ -65,6 +66,7 @@ @camera="showCameraDialog = true" @voice="showVoiceDialog = true" @toggle-check="handleToggleCheckFocused" + @open-url="handleOpenUrlFocused" ref="messageInput" /> @@ -134,6 +136,13 @@ @move="handleMoveMessage" /> + + + + @@ -166,6 +175,7 @@ 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' +import LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue' // Types import type { ExtendedMessage, UnsentMessage, Channel } from '@/types' @@ -198,8 +208,10 @@ const showFileDialog = ref(false) const showVoiceDialog = ref(false) const showMessageDialog = ref(false) const showCameraDialog = ref(false) +const showLinkDialog = ref(false) const selectedMessage = ref(null) const shouldStartEditing = ref(false) +const selectedLinks = ref([]) // Mobile sidebar state const sidebarOpen = ref(false) @@ -345,6 +357,38 @@ const handleToggleCheckFocused = async () => { } } +// Handle opening links from a message (when multiple links found) +const handleOpenLinks = (links: string[], message: ExtendedMessage | UnsentMessage) => { + selectedLinks.value = links + showLinkDialog.value = true +} + +// Handle open URL button press (mobile) - triggers URL opening for focused message +const handleOpenUrlFocused = () => { + const result = messagesContainer.value?.handleOpenUrlFocused?.() + if (!result || !result.message) { + toastStore.info('No message is focused') + return + } + + if (result.action === 'none') { + // No links found, fall back to edit mode + if ('created_at' in result.message) { + handleOpenMessageDialogEdit(result.message) + } else { + toastStore.info('No links found in this message') + } + } else if (result.action === 'single') { + // Single link, open directly + window.open(result.urls[0], '_blank', 'noopener,noreferrer') + toastStore.success('Opening link') + } else if (result.action === 'multiple') { + // Multiple links, show selection dialog + selectedLinks.value = result.urls + showLinkDialog.value = true + } +} + const selectChannel = async (channelId: number) => { console.log('Selecting channel:', channelId) await appStore.setCurrentChannel(channelId)