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)