link opening with shift enter
This commit is contained in:
@@ -39,7 +39,18 @@
|
|||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
class="open-url-button"
|
||||||
|
@click="$emit('open-url')"
|
||||||
|
aria-label="Open URL in focused message"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -70,6 +81,7 @@ defineEmits<{
|
|||||||
'camera': []
|
'camera': []
|
||||||
'voice': []
|
'voice': []
|
||||||
'toggle-check': []
|
'toggle-check': []
|
||||||
|
'open-url': []
|
||||||
'send': []
|
'send': []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -82,9 +94,11 @@ defineEmits<{
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-only for the checked toggle button */
|
/* Mobile-only for the checked toggle button and open URL button */
|
||||||
.input-actions [aria-label="Toggle check on focused message"] { display: none; }
|
.input-actions [aria-label="Toggle check on focused message"],
|
||||||
|
.input-actions .open-url-button { display: none; }
|
||||||
@media (max-width: 480px) {
|
@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; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
@camera="$emit('camera')"
|
@camera="$emit('camera')"
|
||||||
@voice="$emit('voice')"
|
@voice="$emit('voice')"
|
||||||
@toggle-check="$emit('toggle-check')"
|
@toggle-check="$emit('toggle-check')"
|
||||||
|
@open-url="$emit('open-url')"
|
||||||
@send="handleSubmit"
|
@send="handleSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +38,7 @@ const emit = defineEmits<{
|
|||||||
'camera': []
|
'camera': []
|
||||||
'voice': []
|
'voice': []
|
||||||
'toggle-check': []
|
'toggle-check': []
|
||||||
|
'open-url': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ interface Props {
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
'open-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
'open-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||||
'focus': []
|
'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) => {
|
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.)
|
// 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) {
|
||||||
return
|
return
|
||||||
@@ -206,7 +242,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
toggleChecked()
|
toggleChecked()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'c') {
|
if (event.key === 'c') {
|
||||||
// Copy message content (only when no modifiers are pressed)
|
// Copy message content (only when no modifiers are pressed)
|
||||||
navigator.clipboard.writeText(props.message.content)
|
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
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
: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)"
|
@open-dialog="emit('open-message-dialog', $event)"
|
||||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||||
|
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||||
|
|
||||||
<!-- 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"
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
|
||||||
: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)"
|
@open-dialog="emit('open-message-dialog', $event)"
|
||||||
@open-dialog-edit="emit('open-message-dialog-edit', $event)" />
|
@open-dialog-edit="emit('open-message-dialog-edit', $event)"
|
||||||
|
@open-links="(links, msg) => emit('open-links', links, msg)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,6 +37,7 @@ const emit = defineEmits<{
|
|||||||
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
|
||||||
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
|
||||||
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
|
||||||
|
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -246,10 +249,42 @@ const getFocusedMessage = (): ExtendedMessage | UnsentMessage | null => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract URLs from text content
|
||||||
|
const extractUrls = (text: string): string[] => {
|
||||||
|
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/gi
|
||||||
|
const matches = text.match(urlRegex) || []
|
||||||
|
return [...new Set(matches)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle open URL for the focused message (for mobile button)
|
||||||
|
const handleOpenUrlFocused = (): { action: 'none' | 'single' | 'multiple', urls: string[], message: ExtendedMessage | UnsentMessage | null } => {
|
||||||
|
const message = getFocusedMessage()
|
||||||
|
if (!message) {
|
||||||
|
return { action: 'none', urls: [], message: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow URL opening for unsent messages
|
||||||
|
if ('channelId' in message) {
|
||||||
|
return { action: 'none', urls: [], message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = extractUrls(message.content)
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return { action: 'none', urls: [], message }
|
||||||
|
} else if (urls.length === 1) {
|
||||||
|
return { action: 'single', urls, message }
|
||||||
|
} else {
|
||||||
|
return { action: 'multiple', urls, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
focusMessageById,
|
focusMessageById,
|
||||||
getFocusedMessage
|
getFocusedMessage,
|
||||||
|
handleOpenUrlFocused,
|
||||||
|
extractUrls
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
144
frontend-vue/src/components/dialogs/LinkSelectionDialog.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="link-selection-dialog">
|
||||||
|
<p class="link-selection-dialog__description">
|
||||||
|
Select a link to open:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="link-selection-dialog__links">
|
||||||
|
<button
|
||||||
|
v-for="(link, index) in links"
|
||||||
|
:key="index"
|
||||||
|
class="link-selection-dialog__link"
|
||||||
|
@click="openLink(link)"
|
||||||
|
:title="link"
|
||||||
|
>
|
||||||
|
<span class="link-selection-dialog__link-text">{{ formatLink(link) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-selection-dialog__actions">
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
const formatLink = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// Show domain + pathname, truncate if too long
|
||||||
|
let display = parsed.hostname + parsed.pathname
|
||||||
|
if (display.length > 50) {
|
||||||
|
display = display.slice(0, 47) + '...'
|
||||||
|
}
|
||||||
|
return display
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, truncate the raw URL
|
||||||
|
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLink = (url: string) => {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
toastStore.success('Opening link')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-selection-dialog {
|
||||||
|
padding: 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__description {
|
||||||
|
color: #374151;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link:hover,
|
||||||
|
.link-selection-dialog__link:focus {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link-text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.link-selection-dialog__description {
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link {
|
||||||
|
background: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__link:hover,
|
||||||
|
.link-selection-dialog__link:focus {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-selection-dialog__actions {
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
ref="messagesContainer"
|
ref="messagesContainer"
|
||||||
@open-message-dialog="handleOpenMessageDialog"
|
@open-message-dialog="handleOpenMessageDialog"
|
||||||
@open-message-dialog-edit="handleOpenMessageDialogEdit"
|
@open-message-dialog-edit="handleOpenMessageDialogEdit"
|
||||||
|
@open-links="handleOpenLinks"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Message Input -->
|
<!-- Message Input -->
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
@camera="showCameraDialog = true"
|
@camera="showCameraDialog = true"
|
||||||
@voice="showVoiceDialog = true"
|
@voice="showVoiceDialog = true"
|
||||||
@toggle-check="handleToggleCheckFocused"
|
@toggle-check="handleToggleCheckFocused"
|
||||||
|
@open-url="handleOpenUrlFocused"
|
||||||
ref="messageInput"
|
ref="messageInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +136,13 @@
|
|||||||
@move="handleMoveMessage"
|
@move="handleMoveMessage"
|
||||||
/>
|
/>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
|
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
|
||||||
|
<LinkSelectionDialog
|
||||||
|
:links="selectedLinks"
|
||||||
|
@close="showLinkDialog = false"
|
||||||
|
/>
|
||||||
|
</BaseDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -166,6 +175,7 @@ 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'
|
import MessageDialog from '@/components/dialogs/MessageDialog.vue'
|
||||||
|
import LinkSelectionDialog from '@/components/dialogs/LinkSelectionDialog.vue'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
import type { ExtendedMessage, UnsentMessage, Channel } from '@/types'
|
||||||
@@ -198,8 +208,10 @@ const showFileDialog = ref(false)
|
|||||||
const showVoiceDialog = ref(false)
|
const showVoiceDialog = ref(false)
|
||||||
const showMessageDialog = ref(false)
|
const showMessageDialog = ref(false)
|
||||||
const showCameraDialog = ref(false)
|
const showCameraDialog = ref(false)
|
||||||
|
const showLinkDialog = ref(false)
|
||||||
const selectedMessage = ref<ExtendedMessage | null>(null)
|
const selectedMessage = ref<ExtendedMessage | null>(null)
|
||||||
const shouldStartEditing = ref(false)
|
const shouldStartEditing = ref(false)
|
||||||
|
const selectedLinks = ref<string[]>([])
|
||||||
|
|
||||||
// Mobile sidebar state
|
// Mobile sidebar state
|
||||||
const sidebarOpen = ref(false)
|
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) => {
|
const selectChannel = async (channelId: number) => {
|
||||||
console.log('Selecting channel:', channelId)
|
console.log('Selecting channel:', channelId)
|
||||||
await appStore.setCurrentChannel(channelId)
|
await appStore.setCurrentChannel(channelId)
|
||||||
|
|||||||
Reference in New Issue
Block a user