link opening with shift enter

This commit is contained in:
2026-01-09 20:15:58 +00:00
parent 7e49e14901
commit 051c032f1d
6 changed files with 288 additions and 8 deletions

View File

@@ -40,6 +40,17 @@
</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
variant="primary"
size="sm"
@@ -70,6 +81,7 @@ defineEmits<{
'camera': []
'voice': []
'toggle-check': []
'open-url': []
'send': []
}>()
</script>
@@ -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; }
}
</style>

View File

@@ -18,6 +18,7 @@
@camera="$emit('camera')"
@voice="$emit('voice')"
@toggle-check="$emit('toggle-check')"
@open-url="$emit('open-url')"
@send="handleSubmit"
/>
</div>
@@ -37,6 +38,7 @@ const emit = defineEmits<{
'camera': []
'voice': []
'toggle-check': []
'open-url': []
}>()
const appStore = useAppStore()

View File

@@ -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
@@ -341,6 +377,11 @@ const toggleChecked = async () => {
}
}
// Expose methods for external use (e.g., mobile button)
defineExpose({
handleOpenUrl,
extractUrls
})
</script>
<style scoped>

View File

@@ -8,7 +8,8 @@
:aria-selected="index === focusedMessageIndex ? 'true' : 'false'"
@focus="focusedMessageIndex = index"
@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 -->
<MessageItem v-for="(unsentMsg, index) in unsentMessages" :key="unsentMsg.id" :message="unsentMsg"
@@ -16,7 +17,8 @@
:aria-selected="(messages.length + index) === focusedMessageIndex ? 'true' : 'false'"
:data-message-index="messages.length + index" @focus="focusedMessageIndex = messages.length + index"
@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>
</template>
@@ -35,6 +37,7 @@ const emit = defineEmits<{
'message-selected': [message: ExtendedMessage | UnsentMessage, index: number]
'open-message-dialog': [message: ExtendedMessage | UnsentMessage]
'open-message-dialog-edit': [message: ExtendedMessage | UnsentMessage]
'open-links': [links: string[], message: ExtendedMessage | UnsentMessage]
}>()
const props = defineProps<Props>()
@@ -246,10 +249,42 @@ const getFocusedMessage = (): ExtendedMessage | UnsentMessage | 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({
scrollToBottom,
focusMessageById,
getFocusedMessage
getFocusedMessage,
handleOpenUrlFocused,
extractUrls
})
</script>

View 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>

View File

@@ -56,6 +56,7 @@
ref="messagesContainer"
@open-message-dialog="handleOpenMessageDialog"
@open-message-dialog-edit="handleOpenMessageDialogEdit"
@open-links="handleOpenLinks"
/>
<!-- Message Input -->
@@ -65,6 +66,7 @@
@camera="showCameraDialog = true"
@voice="showVoiceDialog = true"
@toggle-check="handleToggleCheckFocused"
@open-url="handleOpenUrlFocused"
ref="messageInput"
/>
</div>
@@ -134,6 +136,13 @@
@move="handleMoveMessage"
/>
</BaseDialog>
<BaseDialog v-model:show="showLinkDialog" title="Open Link">
<LinkSelectionDialog
:links="selectedLinks"
@close="showLinkDialog = false"
/>
</BaseDialog>
</div>
</template>
@@ -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<ExtendedMessage | null>(null)
const shouldStartEditing = ref(false)
const selectedLinks = ref<string[]>([])
// 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)