mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-22 18:43:33 +00:00
Merge pull request #289 from mageaustralia/feat/image-attachment-ux
Inline images, lightbox, and attachment UX
This commit is contained in:
+13
-6
@@ -101,7 +101,7 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
|
||||
// Generate and upload thumbnail and store image dimensions in the media meta.
|
||||
var meta = []byte("{}")
|
||||
if slices.Contains(image.Exts, srcExt) {
|
||||
if slices.Contains(image.Exts, srcExt) || image.IsImageByContent(file) {
|
||||
file.Seek(0, 0)
|
||||
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
|
||||
if err != nil {
|
||||
@@ -152,8 +152,8 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
// Supports both authenticated access (with permission checks) and signed URL access (no permission checks).
|
||||
func handleServeMedia(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
authMethod = r.RequestCtx.UserValue("auth_method")
|
||||
)
|
||||
|
||||
@@ -184,7 +184,7 @@ func handleServeMedia(r *fastglue.Request) error {
|
||||
|
||||
// For messages, check access to the conversation this message is part of.
|
||||
// Skip if model_id is not set (media uploaded but not yet attached to a message).
|
||||
if media.Model.String == "messages" && media.ModelID.Int > 0 {
|
||||
if media.Model.String == mmodels.ModelMessages && media.ModelID.Int > 0 {
|
||||
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -214,13 +214,16 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M
|
||||
media = &m
|
||||
}
|
||||
|
||||
forceDownload := string(r.RequestCtx.QueryArgs().Peek("download")) == "1"
|
||||
|
||||
consts := app.consts.Load().(*constants)
|
||||
switch consts.UploadProvider {
|
||||
case "fs":
|
||||
disposition := "attachment"
|
||||
|
||||
// Inline images/videos/pdfs. SVG excluded.
|
||||
if media.ContentType != "image/svg+xml" &&
|
||||
if !forceDownload &&
|
||||
media.ContentType != "image/svg+xml" &&
|
||||
(strings.HasPrefix(media.ContentType, "image/") ||
|
||||
strings.HasPrefix(media.ContentType, "video/") ||
|
||||
media.ContentType == "application/pdf") {
|
||||
@@ -233,7 +236,11 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M
|
||||
|
||||
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
|
||||
case "s3":
|
||||
r.RequestCtx.Redirect(app.media.GetURL(uuid, media.ContentType, media.Filename), http.StatusFound)
|
||||
url := app.media.GetURL(uuid, media.ContentType, media.Filename)
|
||||
if forceDownload {
|
||||
url = app.media.GetURLForDownload(uuid, media.Filename)
|
||||
}
|
||||
r.RequestCtx.Redirect(url, http.StatusFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<a
|
||||
:href="downloadUrl(url)"
|
||||
:title="t('globals.terms.download')"
|
||||
:aria-label="t('globals.terms.download')"
|
||||
class="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
<Download :size="size" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloadUrl } from '@shared-ui/utils/file'
|
||||
|
||||
defineProps({
|
||||
url: { type: String, required: true },
|
||||
size: { type: Number, default: 16 }
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<VueEasyLightbox
|
||||
:visible="modelValue"
|
||||
:imgs="imgs"
|
||||
:index="index"
|
||||
:loop="images.length > 1"
|
||||
teleport="body"
|
||||
@hide="close"
|
||||
@on-index-change="onIndexChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, required: true },
|
||||
images: { type: Array, required: true },
|
||||
startIndex: { type: Number, default: 0 }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const index = ref(0)
|
||||
|
||||
const imgs = computed(() =>
|
||||
props.images.map((img) => ({ src: img.url, title: img.name || '' }))
|
||||
)
|
||||
|
||||
function clamp(n, min, max) {
|
||||
return Math.min(Math.max(n, min), max)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onIndexChange(_prev, next) {
|
||||
index.value = next
|
||||
}
|
||||
|
||||
function step(delta) {
|
||||
const total = props.images.length
|
||||
if (total <= 1) return
|
||||
index.value = (index.value + delta + total) % total
|
||||
}
|
||||
|
||||
const KEY_ACTIONS = {
|
||||
ArrowLeft: () => step(-1),
|
||||
ArrowRight: () => step(1),
|
||||
Escape: close,
|
||||
ArrowUp: () => {},
|
||||
ArrowDown: () => {},
|
||||
PageUp: () => {},
|
||||
PageDown: () => {}
|
||||
}
|
||||
|
||||
// Capture phase so we run before the lib's bubble listener AND before sibling
|
||||
// listeners (message-list virtualizer, etc.) see the key.
|
||||
function onDocKeydown(e) {
|
||||
if (!props.modelValue) return
|
||||
const action = KEY_ACTIONS[e.key]
|
||||
if (!action) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
action()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
index.value = clamp(props.startIndex, 0, props.images.length - 1)
|
||||
document.addEventListener('keydown', onDocKeydown, true)
|
||||
} else {
|
||||
document.removeEventListener('keydown', onDocKeydown, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.vel-img-wrapper,
|
||||
.vel-img,
|
||||
.vel-fade-enter-active,
|
||||
.vel-fade-leave-active {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
:href="appSettingsStore.settings['app.update'].update.url"
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer"
|
||||
rel="nofollow noopener noreferrer"
|
||||
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.release_version }}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<BubbleMenu
|
||||
:editor="editor"
|
||||
:tippy-options="{ duration: 100 }"
|
||||
:should-show="shouldShowBubble"
|
||||
v-if="editor"
|
||||
class="bg-background p-1 box will-change-transform"
|
||||
>
|
||||
@@ -141,7 +142,7 @@ import {
|
||||
DialogDescription
|
||||
} from '@shared-ui/components/ui/dialog'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import ResizableImage from './extensions/ResizableImage'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
@@ -151,6 +152,7 @@ import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import { useTypingIndicator } from '@shared-ui/composables'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { useInlineImageUpload } from '@main/composables/useInlineImageUpload'
|
||||
import mentionSuggestion from './mentionSuggestion'
|
||||
|
||||
const textContent = defineModel('textContent', { default: '' })
|
||||
@@ -181,13 +183,33 @@ const props = defineProps({
|
||||
getSuggestions: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
enableInlineImages: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged'])
|
||||
const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged', 'filesDropped'])
|
||||
|
||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
// Suppress the formatting bubble when an image node is selected so it
|
||||
// doesn't fight with the image's own size/remove toolbar.
|
||||
const shouldShowBubble = ({ editor: e, state }) => {
|
||||
const { selection } = state
|
||||
if (selection.empty) return false
|
||||
if (!e.view.hasFocus()) return false
|
||||
if (selection.node?.type?.name === 'image') return false
|
||||
return true
|
||||
}
|
||||
|
||||
const { handlePaste, handleDrop } = useInlineImageUpload({
|
||||
getEditor: () => editor.value,
|
||||
isInlineEnabled: () => props.enableInlineImages,
|
||||
onOtherFiles: (files) => emit('filesDropped', files)
|
||||
})
|
||||
|
||||
// Set up typing indicator
|
||||
const conversationStore = useConversationStore()
|
||||
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping, {
|
||||
@@ -257,7 +279,10 @@ const isInternalUpdate = ref(false)
|
||||
const buildExtensions = () => {
|
||||
const extensions = [
|
||||
StarterKit.configure(),
|
||||
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
|
||||
ResizableImage.configure({
|
||||
HTMLAttributes: { class: 'inline-image', style: 'max-width: 100%; height: auto;' },
|
||||
allowBase64: false
|
||||
}),
|
||||
Placeholder.configure({ placeholder: () => props.placeholder }),
|
||||
Link,
|
||||
CustomTable.configure({ resizable: false }),
|
||||
@@ -309,6 +334,8 @@ const editor = useEditor({
|
||||
editorProps: {
|
||||
attributes: { class: 'outline-none' },
|
||||
getSuggestions: props.getSuggestions,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
|
||||
event.stopPropagation()
|
||||
@@ -451,5 +478,138 @@ defineExpose({ focus, extractMentions })
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-resizer {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 4px 5px;
|
||||
|
||||
.image-upload-placeholder {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px 32px;
|
||||
min-width: 360px;
|
||||
min-height: 220px;
|
||||
max-width: 100%;
|
||||
background: hsl(var(--muted));
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-upload-placeholder-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.image-upload-placeholder-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
.inline-image {
|
||||
display: none;
|
||||
}
|
||||
.image-upload-placeholder {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.image-resize-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: hsl(var(--primary));
|
||||
border: 2px solid hsl(var(--background));
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 0 1px hsl(var(--border));
|
||||
}
|
||||
|
||||
.image-resize-handle-tl { top: -6px; left: -6px; cursor: nwse-resize; }
|
||||
.image-resize-handle-tr { top: -6px; right: -6px; cursor: nesw-resize; }
|
||||
.image-resize-handle-bl { bottom: -6px; left: -6px; cursor: nesw-resize; }
|
||||
.image-resize-handle-br { bottom: -6px; right: -6px; cursor: nwse-resize; }
|
||||
|
||||
// Anchored to the image's left edge (no centering) so the toolbar
|
||||
// never extends past the image's left side and into adjacent UI when
|
||||
// the image sits near the editor's left edge.
|
||||
.image-size-toolbar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
background: hsl(var(--background) / 0.95);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
z-index: 10000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
button {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
line-height: 1.6;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
.image-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: hsl(var(--border));
|
||||
margin: 0 2px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.image-toolbar-remove {
|
||||
color: hsl(var(--destructive)) !important;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--destructive) / 0.1) !important;
|
||||
color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode .image-resize-handle,
|
||||
&.resizing .image-resize-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode .image-size-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode .inline-image,
|
||||
&.resizing .inline-image {
|
||||
outline: 2px solid #0066cc;
|
||||
}
|
||||
|
||||
&.resizing .inline-image {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
import { getI18n } from '@main/i18n'
|
||||
|
||||
// Styles for `.image-resizer`, `.image-resize-handle*`, `.image-size-toolbar`,
|
||||
// and `.image-upload-placeholder*` are in TextEditor.vue's global <style>
|
||||
// block because they need to apply inside the tiptap-rendered DOM.
|
||||
export const ResizableImage = Image.extend({
|
||||
addAttributes () {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('width') || el.style.width?.replace('px', '') || null,
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.width) return {}
|
||||
return { width: attrs.width, style: `width: ${attrs.width}px` }
|
||||
}
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('height') || null,
|
||||
renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {})
|
||||
},
|
||||
// Transient placeholder state - never persisted in HTML.
|
||||
uploading: {
|
||||
default: false,
|
||||
parseHTML: () => false,
|
||||
renderHTML: () => ({})
|
||||
},
|
||||
uploadId: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({})
|
||||
},
|
||||
uploadName: {
|
||||
default: null,
|
||||
parseHTML: () => null,
|
||||
renderHTML: () => ({})
|
||||
}
|
||||
}
|
||||
},
|
||||
renderHTML (props) {
|
||||
// Don't serialize uploading placeholders - they shouldn't end up in
|
||||
// saved drafts or sent messages.
|
||||
if (props.node.attrs.uploading) {
|
||||
return ['span', { 'data-upload-placeholder': '' }]
|
||||
}
|
||||
return this.parent?.(props) ?? ['img', props.HTMLAttributes]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor: nodeEditor }) => {
|
||||
const t = getI18n().global.t
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.classList.add('image-resizer')
|
||||
wrapper.style.display = 'inline-block'
|
||||
wrapper.style.position = 'relative'
|
||||
wrapper.style.lineHeight = '0'
|
||||
|
||||
const placeholder = document.createElement('div')
|
||||
placeholder.classList.add('image-upload-placeholder')
|
||||
const placeholderRow = document.createElement('div')
|
||||
placeholderRow.classList.add('image-upload-placeholder-row')
|
||||
const spinner = document.createElement('div')
|
||||
spinner.className = 'w-7 h-7 border-2 border-muted-foreground border-t-primary rounded-full animate-spin'
|
||||
const nameEl = document.createElement('span')
|
||||
nameEl.classList.add('image-upload-placeholder-name')
|
||||
placeholderRow.appendChild(spinner)
|
||||
placeholderRow.appendChild(nameEl)
|
||||
placeholder.appendChild(placeholderRow)
|
||||
wrapper.appendChild(placeholder)
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.classList.add('inline-image')
|
||||
img.style.maxWidth = '100%'
|
||||
img.style.height = 'auto'
|
||||
wrapper.appendChild(img)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.classList.add('image-size-toolbar')
|
||||
|
||||
let naturalWidth = 0
|
||||
img.addEventListener('load', () => { naturalWidth = img.naturalWidth })
|
||||
|
||||
const commitWidth = (newWidth) => {
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
const current = nodeEditor.state.doc.nodeAt(pos)
|
||||
if (!current) return
|
||||
nodeEditor.chain().focus().command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, { ...current.attrs, width: newWidth || null })
|
||||
return true
|
||||
}).run()
|
||||
}
|
||||
|
||||
const clampToNatural = (w) => (naturalWidth ? Math.min(w, naturalWidth) : w)
|
||||
const sizes = [
|
||||
{ label: t('globals.terms.small'), getWidth: () => clampToNatural(400) },
|
||||
{ label: t('globals.messages.bestFit'), getWidth: () => clampToNatural(nodeEditor.view.dom.clientWidth) }
|
||||
]
|
||||
// Toolbar buttons use pointerdown so touch + pen + mouse all work.
|
||||
// preventDefault avoids stealing focus from the editor.
|
||||
sizes.forEach(({ label, getWidth }) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.textContent = label
|
||||
btn.type = 'button'
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const w = getWidth()
|
||||
img.style.width = w + 'px'
|
||||
commitWidth(w)
|
||||
})
|
||||
toolbar.appendChild(btn)
|
||||
})
|
||||
|
||||
const sep = document.createElement('span')
|
||||
sep.classList.add('image-toolbar-sep')
|
||||
toolbar.appendChild(sep)
|
||||
|
||||
const removeBtn = document.createElement('button')
|
||||
removeBtn.textContent = t('globals.terms.remove')
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.classList.add('image-toolbar-remove')
|
||||
removeBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pos = getPos()
|
||||
if (typeof pos === 'number') {
|
||||
nodeEditor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(removeBtn)
|
||||
wrapper.appendChild(toolbar)
|
||||
|
||||
// Inline images grow rightward in text flow, so width is the only
|
||||
// axis we can actually change. Left-side handles flip the sign so
|
||||
// dragging outward in either direction reads as "grow."
|
||||
const corners = [
|
||||
{ className: 'image-resize-handle-tl', direction: -1 },
|
||||
{ className: 'image-resize-handle-tr', direction: 1 },
|
||||
{ className: 'image-resize-handle-bl', direction: -1 },
|
||||
{ className: 'image-resize-handle-br', direction: 1 }
|
||||
]
|
||||
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
let activeDirection = 1
|
||||
const onPointerMove = (e) => {
|
||||
const newWidth = Math.max(50, startWidth + activeDirection * (e.clientX - startX))
|
||||
img.style.width = newWidth + 'px'
|
||||
}
|
||||
const onPointerUp = () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.remove('resizing')
|
||||
try {
|
||||
commitWidth(Math.round(img.offsetWidth))
|
||||
} catch (err) {
|
||||
// Node may have been removed/replaced mid-drag (autosave
|
||||
// re-render, paste over selection, etc.). Drop the commit.
|
||||
}
|
||||
}
|
||||
|
||||
corners.forEach(({ className, direction }) => {
|
||||
const handle = document.createElement('div')
|
||||
handle.classList.add('image-resize-handle', className)
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
startX = e.clientX
|
||||
startWidth = img.offsetWidth
|
||||
activeDirection = direction
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.add('resizing')
|
||||
})
|
||||
wrapper.appendChild(handle)
|
||||
})
|
||||
|
||||
const applyState = (n) => {
|
||||
if (n.attrs.uploading) {
|
||||
wrapper.classList.add('uploading')
|
||||
nameEl.textContent = n.attrs.uploadName || ''
|
||||
} else {
|
||||
wrapper.classList.remove('uploading')
|
||||
img.src = n.attrs.src
|
||||
img.alt = n.attrs.alt || ''
|
||||
img.title = n.attrs.title || ''
|
||||
img.style.width = n.attrs.width ? n.attrs.width + 'px' : ''
|
||||
}
|
||||
}
|
||||
applyState(node)
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== 'image') return false
|
||||
applyState(updatedNode)
|
||||
return true
|
||||
},
|
||||
// Drag listeners are added on pointerdown and torn down on pointerup,
|
||||
// but if the nodeView is destroyed mid-drag those window listeners
|
||||
// would leak.
|
||||
destroy: () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default ResizableImage
|
||||
@@ -25,9 +25,39 @@ export function useFileUpload (options = {}) {
|
||||
const isUploading = ref(false)
|
||||
const internalMediaFiles = ref([])
|
||||
|
||||
// Use external mediaFiles if provided, otherwise use internal
|
||||
const mediaFiles = externalMediaFiles || internalMediaFiles
|
||||
|
||||
/**
|
||||
* Returns the media record or null on failure (toast fires).
|
||||
*
|
||||
* `inline: true` flags `disposition=inline` server-side; without it an
|
||||
* editor-embedded image would also surface as a downloadable attachment
|
||||
* under the message (MessageBubble filters by disposition).
|
||||
*
|
||||
* @param {File} file
|
||||
* @param {{ inline?: boolean }} opts
|
||||
*/
|
||||
const upload = async (file, { inline = false } = {}) => {
|
||||
try {
|
||||
const resp = await api.uploadMedia({
|
||||
files: file,
|
||||
inline,
|
||||
linked_model: linkedModel
|
||||
})
|
||||
return resp.data.data
|
||||
} catch (error) {
|
||||
if (onUploadError) {
|
||||
onUploadError(file, error)
|
||||
} else {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file upload process when files are selected.
|
||||
* Uploads each file to the server and adds them to the mediaFiles array.
|
||||
@@ -39,53 +69,22 @@ export function useFileUpload (options = {}) {
|
||||
isUploading.value = true
|
||||
|
||||
for (const file of files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: false,
|
||||
linked_model: linkedModel
|
||||
})
|
||||
.then((resp) => {
|
||||
const uploadedFile = resp.data.data
|
||||
|
||||
// Add to media files array
|
||||
upload(file).then((uploadedFile) => {
|
||||
if (uploadedFile) {
|
||||
if (Array.isArray(mediaFiles.value)) {
|
||||
mediaFiles.value.push(uploadedFile)
|
||||
} else {
|
||||
mediaFiles.push(uploadedFile)
|
||||
}
|
||||
|
||||
// Remove from uploading list
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
|
||||
// Call success callback
|
||||
if (onFileUploadSuccess) {
|
||||
onFileUploadSuccess(uploadedFile)
|
||||
}
|
||||
|
||||
// Update uploading state
|
||||
if (uploadingFiles.value.length === 0) {
|
||||
isUploading.value = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
|
||||
// Call error callback or show default toast
|
||||
if (onUploadError) {
|
||||
onUploadError(file, error)
|
||||
} else {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
|
||||
// Update uploading state
|
||||
if (uploadingFiles.value.length === 0) {
|
||||
isUploading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
if (uploadingFiles.value.length === 0) {
|
||||
isUploading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,10 +146,11 @@ export function useFileUpload (options = {}) {
|
||||
mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
|
||||
|
||||
// Methods
|
||||
upload,
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
uploadFiles,
|
||||
clearMediaFiles,
|
||||
setMediaFiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useFileUpload } from './useFileUpload'
|
||||
|
||||
// Must match ResizableImage's renderHTML output.
|
||||
const INLINE_IMAGE_MARKER = 'class="inline-image"'
|
||||
const UPLOAD_PLACEHOLDER_MARKER = 'data-upload-placeholder'
|
||||
|
||||
export const hasInlineImage = (html) => (html || '').includes(INLINE_IMAGE_MARKER)
|
||||
export const hasPendingInlineUpload = (html) => (html || '').includes(UPLOAD_PLACEHOLDER_MARKER)
|
||||
|
||||
/**
|
||||
* `getEditor` is a function (not a ref) because the editor doesn't exist
|
||||
* when the composable is called - `useEditor()`'s `editorProps` needs
|
||||
* `handlePaste` and `handleDrop` available up front.
|
||||
*
|
||||
* `isInlineEnabled` is a getter so per-conversation channel changes are
|
||||
* picked up live. When false, images route through `onOtherFiles`: used
|
||||
* for livechat, where signed URLs inside stored HTML age out but
|
||||
* attachment URLs get re-signed on every fetch.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {() => Object} options.getEditor
|
||||
* @param {() => boolean} [options.isInlineEnabled]
|
||||
* @param {(files: File[]) => void} [options.onOtherFiles]
|
||||
* @param {string} [options.linkedModel='messages']
|
||||
* @param {number} [options.maxInlineImages=50]
|
||||
*/
|
||||
export function useInlineImageUpload ({
|
||||
getEditor,
|
||||
isInlineEnabled = () => true,
|
||||
onOtherFiles,
|
||||
linkedModel = 'messages',
|
||||
maxInlineImages = 50
|
||||
} = {}) {
|
||||
const { upload } = useFileUpload({ linkedModel })
|
||||
|
||||
const countInlineImages = () => {
|
||||
const editor = getEditor()
|
||||
if (!editor) return 0
|
||||
let count = 0
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'image') count++
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
const remainingSlots = () => Math.max(0, maxInlineImages - countInlineImages())
|
||||
|
||||
const newUploadId = () =>
|
||||
typeof crypto?.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `ul-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
// Right-to-left so positions stay valid across deletes; harmless for
|
||||
// setNodeMarkup which doesn't shift positions.
|
||||
const mutateImagesByUploadId = (uploadId, mutate) => {
|
||||
const editor = getEditor()
|
||||
if (!editor) return
|
||||
const matches = []
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'image' && node.attrs.uploadId === uploadId) {
|
||||
matches.push({ pos, node })
|
||||
}
|
||||
})
|
||||
if (matches.length === 0) return
|
||||
const tr = editor.state.tr
|
||||
for (const { pos, node } of matches.sort((a, b) => b.pos - a.pos)) {
|
||||
mutate(tr, pos, node)
|
||||
}
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
const replacePlaceholder = (uploadId, src) =>
|
||||
mutateImagesByUploadId(uploadId, (tr, pos, node) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
src,
|
||||
uploading: false,
|
||||
uploadId: null,
|
||||
uploadName: null
|
||||
})
|
||||
})
|
||||
|
||||
const removePlaceholder = (uploadId) =>
|
||||
mutateImagesByUploadId(uploadId, (tr, pos, node) => {
|
||||
tr.delete(pos, pos + node.nodeSize)
|
||||
})
|
||||
|
||||
// Single insertContent with N nodes - not a loop of setImage calls,
|
||||
// which would each NodeSelection-select the inserted image and the
|
||||
// next call would replace it instead of appending.
|
||||
const uploadAndInsertInOrder = async (files) => {
|
||||
const editor = getEditor()
|
||||
if (!editor || files.length === 0) return
|
||||
const pending = files.map((file) => ({ file, uploadId: newUploadId() }))
|
||||
const nodes = pending.map(({ file, uploadId }) => ({
|
||||
type: 'image',
|
||||
attrs: { src: '', uploading: true, uploadId, uploadName: file.name }
|
||||
}))
|
||||
editor.chain().focus().insertContent(nodes).run()
|
||||
|
||||
await Promise.all(
|
||||
pending.map(async ({ file, uploadId }) => {
|
||||
const media = await upload(file, { inline: true })
|
||||
if (media?.url) replacePlaceholder(uploadId, media.url)
|
||||
else removePlaceholder(uploadId)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const acceptImages = (images) => {
|
||||
const allowed = images.slice(0, remainingSlots())
|
||||
if (allowed.length > 0) uploadAndInsertInOrder(allowed)
|
||||
}
|
||||
|
||||
const dispatchFiles = (event, fileList) => {
|
||||
const imageFiles = []
|
||||
const otherFiles = []
|
||||
for (const file of fileList) {
|
||||
// Force SVG into other.
|
||||
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
||||
imageFiles.push(file)
|
||||
} else {
|
||||
otherFiles.push(file)
|
||||
}
|
||||
}
|
||||
if (imageFiles.length === 0 && otherFiles.length === 0) return false
|
||||
event.preventDefault()
|
||||
if (isInlineEnabled()) {
|
||||
acceptImages(imageFiles)
|
||||
if (otherFiles.length > 0 && onOtherFiles) onOtherFiles(otherFiles)
|
||||
} else if (onOtherFiles && (imageFiles.length > 0 || otherFiles.length > 0)) {
|
||||
onOtherFiles([...imageFiles, ...otherFiles])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handlePaste = (view, event) => {
|
||||
const data = event.clipboardData
|
||||
if (!data) return false
|
||||
|
||||
// OS-level file paste (file manager): `files` reliably exposes all
|
||||
// entries, unlike `items` which some browsers only populate with
|
||||
// the first one.
|
||||
if (data.files && data.files.length > 0) {
|
||||
return dispatchFiles(event, data.files)
|
||||
}
|
||||
|
||||
// Rich-content pastes (Google Docs, Word, web pages) carry text/html
|
||||
// alongside any image data. Let ProseMirror handle so we don't strip
|
||||
// the text.
|
||||
const types = Array.from(data.types || [])
|
||||
if (types.includes('text/html') || types.includes('text/plain')) return false
|
||||
|
||||
const filesFromItems = []
|
||||
for (const item of data.items || []) {
|
||||
if (item.kind !== 'file') continue
|
||||
const file = item.getAsFile()
|
||||
if (file) filesFromItems.push(file)
|
||||
}
|
||||
if (filesFromItems.length === 0) return false
|
||||
return dispatchFiles(event, filesFromItems)
|
||||
}
|
||||
|
||||
const handleDrop = (view, event) => {
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return false
|
||||
return dispatchFiles(event, files)
|
||||
}
|
||||
|
||||
return { handlePaste, handleDrop }
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('./useFileUpload', () => ({
|
||||
useFileUpload: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: '/uploads/abc' })
|
||||
})
|
||||
}))
|
||||
|
||||
const { useInlineImageUpload, hasInlineImage, hasPendingInlineUpload } =
|
||||
await import('./useInlineImageUpload')
|
||||
|
||||
describe('hasInlineImage', () => {
|
||||
test('matches inline-image class', () => {
|
||||
expect(hasInlineImage('<img class="inline-image" src="/x">')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects unrelated img tags', () => {
|
||||
expect(hasInlineImage('<img src="/x" class="something-else">')).toBe(false)
|
||||
})
|
||||
|
||||
test('handles falsy input', () => {
|
||||
expect(hasInlineImage(null)).toBe(false)
|
||||
expect(hasInlineImage(undefined)).toBe(false)
|
||||
expect(hasInlineImage('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPendingInlineUpload', () => {
|
||||
test('matches data-upload-placeholder', () => {
|
||||
expect(hasPendingInlineUpload('<span data-upload-placeholder=""></span>')).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects content without marker', () => {
|
||||
expect(hasPendingInlineUpload('<p>hello</p>')).toBe(false)
|
||||
expect(hasPendingInlineUpload('<img class="inline-image" src="/x">')).toBe(false)
|
||||
})
|
||||
|
||||
test('handles falsy input', () => {
|
||||
expect(hasPendingInlineUpload(null)).toBe(false)
|
||||
expect(hasPendingInlineUpload('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const makeFile = (name, type) => new File(['x'], name, { type })
|
||||
|
||||
const makeEditor = (initialImages = []) => {
|
||||
const nodes = initialImages.map((attrs, i) => ({
|
||||
type: { name: 'image' },
|
||||
attrs,
|
||||
nodeSize: 1,
|
||||
__pos: i
|
||||
}))
|
||||
const insertContent = vi.fn()
|
||||
return {
|
||||
editor: {
|
||||
state: {
|
||||
doc: {
|
||||
descendants: (cb) => nodes.forEach((n) => cb(n, n.__pos))
|
||||
},
|
||||
get tr() {
|
||||
return { setNodeMarkup: vi.fn(), delete: vi.fn() }
|
||||
}
|
||||
},
|
||||
view: { dispatch: vi.fn() },
|
||||
chain: () => ({
|
||||
focus: () => ({
|
||||
insertContent: (n) => {
|
||||
insertContent(n)
|
||||
return { run: vi.fn() }
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
insertContent
|
||||
}
|
||||
}
|
||||
|
||||
const makeClipboardEvent = ({ files = [], items = [], types = [] }) => ({
|
||||
clipboardData: {
|
||||
files: files.length > 0 ? files : null,
|
||||
items,
|
||||
types
|
||||
},
|
||||
preventDefault: vi.fn()
|
||||
})
|
||||
|
||||
describe('useInlineImageUpload', () => {
|
||||
test('returns handlePaste and handleDrop', () => {
|
||||
const { editor } = makeEditor()
|
||||
const hooks = useInlineImageUpload({ getEditor: () => editor })
|
||||
expect(typeof hooks.handlePaste).toBe('function')
|
||||
expect(typeof hooks.handleDrop).toBe('function')
|
||||
})
|
||||
|
||||
test('handlePaste returns false when clipboardData is null', () => {
|
||||
const { editor } = makeEditor()
|
||||
const { handlePaste } = useInlineImageUpload({ getEditor: () => editor })
|
||||
expect(handlePaste({}, { clipboardData: null })).toBe(false)
|
||||
})
|
||||
|
||||
test('handlePaste bails on text/html paste so ProseMirror handles rich content', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const event = makeClipboardEvent({ types: ['text/html', 'text/plain'] })
|
||||
expect(handlePaste({}, event)).toBe(false)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handlePaste with text/html AND an image item does not intercept the image', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const png = makeFile('embedded.png', 'image/png')
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: null,
|
||||
items: [
|
||||
{ kind: 'string', type: 'text/html' },
|
||||
{ kind: 'string', type: 'text/plain' },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => png }
|
||||
],
|
||||
types: ['text/html', 'text/plain', 'image/png']
|
||||
},
|
||||
preventDefault: vi.fn()
|
||||
}
|
||||
expect(handlePaste({}, event)).toBe(false)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handlePaste inserts placeholder for image file', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const png = makeFile('a.png', 'image/png')
|
||||
expect(handlePaste({}, makeClipboardEvent({ files: [png] }))).toBe(true)
|
||||
expect(insertContent).toHaveBeenCalledTimes(1)
|
||||
const nodes = insertContent.mock.calls[0][0]
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]).toMatchObject({
|
||||
type: 'image',
|
||||
attrs: { uploading: true, uploadName: 'a.png' }
|
||||
})
|
||||
expect(onOtherFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('handlePaste routes SVG to onOtherFiles (not inline)', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const svg = makeFile('icon.svg', 'image/svg+xml')
|
||||
expect(handlePaste({}, makeClipboardEvent({ files: [svg] }))).toBe(true)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([svg])
|
||||
})
|
||||
|
||||
test('handlePaste routes non-image file to onOtherFiles', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const csv = makeFile('a.csv', 'text/csv')
|
||||
expect(handlePaste({}, makeClipboardEvent({ files: [csv] }))).toBe(true)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([csv])
|
||||
})
|
||||
|
||||
test('handlePaste partitions mixed image and non-image files', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const event = makeClipboardEvent({
|
||||
files: [
|
||||
makeFile('a.png', 'image/png'),
|
||||
makeFile('b.csv', 'text/csv'),
|
||||
makeFile('c.jpg', 'image/jpeg')
|
||||
]
|
||||
})
|
||||
expect(handlePaste({}, event)).toBe(true)
|
||||
expect(insertContent.mock.calls[0][0]).toHaveLength(2)
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: 'b.csv' })
|
||||
])
|
||||
})
|
||||
|
||||
test('handlePaste items branch picks up non-image file items', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const csv = makeFile('a.csv', 'text/csv')
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: null,
|
||||
items: [{ kind: 'file', type: 'text/csv', getAsFile: () => csv }],
|
||||
types: ['Files']
|
||||
},
|
||||
preventDefault: vi.fn()
|
||||
}
|
||||
expect(handlePaste({}, event)).toBe(true)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([csv])
|
||||
})
|
||||
|
||||
test('isInlineEnabled=false routes images to onOtherFiles', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
isInlineEnabled: () => false,
|
||||
onOtherFiles
|
||||
})
|
||||
const png = makeFile('a.png', 'image/png')
|
||||
expect(handlePaste({}, makeClipboardEvent({ files: [png] }))).toBe(true)
|
||||
expect(insertContent).not.toHaveBeenCalled()
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([png])
|
||||
})
|
||||
|
||||
test('maxInlineImages cap truncates inline insertions', () => {
|
||||
const { editor, insertContent } = makeEditor([
|
||||
{ uploading: false },
|
||||
{ uploading: false }
|
||||
])
|
||||
const { handlePaste } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
maxInlineImages: 3
|
||||
})
|
||||
const event = makeClipboardEvent({
|
||||
files: [
|
||||
makeFile('1.png', 'image/png'),
|
||||
makeFile('2.png', 'image/png'),
|
||||
makeFile('3.png', 'image/png'),
|
||||
makeFile('4.png', 'image/png')
|
||||
]
|
||||
})
|
||||
expect(handlePaste({}, event)).toBe(true)
|
||||
expect(insertContent.mock.calls[0][0]).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('handleDrop dispatches files like a paste', () => {
|
||||
const { editor, insertContent } = makeEditor()
|
||||
const onOtherFiles = vi.fn()
|
||||
const { handleDrop } = useInlineImageUpload({
|
||||
getEditor: () => editor,
|
||||
onOtherFiles
|
||||
})
|
||||
const event = {
|
||||
dataTransfer: {
|
||||
files: [makeFile('a.png', 'image/png'), makeFile('b.pdf', 'application/pdf')]
|
||||
},
|
||||
preventDefault: vi.fn()
|
||||
}
|
||||
expect(handleDrop({}, event)).toBe(true)
|
||||
expect(insertContent.mock.calls[0][0]).toHaveLength(1)
|
||||
expect(onOtherFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: 'b.pdf' })
|
||||
])
|
||||
})
|
||||
|
||||
test('handleDrop returns false with no files', () => {
|
||||
const { editor } = makeEditor()
|
||||
const { handleDrop } = useInlineImageUpload({ getEditor: () => editor })
|
||||
expect(handleDrop({}, { dataTransfer: { files: [] } })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -694,6 +694,7 @@
|
||||
: 'https://entra.microsoft.com/'
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary underline"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -200,8 +200,10 @@
|
||||
:placeholder="t('editor.hint.newLineCtrlK')"
|
||||
:insertContent="insertContent"
|
||||
:autoFocus="false"
|
||||
:enableInlineImages="true"
|
||||
class="w-full flex-1 overflow-y-auto p-2 box min-h-0"
|
||||
@send="createConversation"
|
||||
@filesDropped="uploadFiles"
|
||||
/>
|
||||
|
||||
<MacroActionsPreview
|
||||
@@ -222,7 +224,7 @@
|
||||
class="mt-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<AttachmentsPreview
|
||||
<ReplyBoxAttachmentPreview
|
||||
:attachments="mediaFiles"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleFileDelete"
|
||||
@@ -274,7 +276,7 @@ import {
|
||||
} from '@shared-ui/components/ui/form'
|
||||
import { z } from 'zod'
|
||||
import { ref, watch, onUnmounted, nextTick, onMounted, computed } from 'vue'
|
||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
||||
import ReplyBoxAttachmentPreview from '@/features/conversation/message/attachment/ReplyBoxAttachmentPreview.vue'
|
||||
import { useConversationStore } from '../../stores/conversation'
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
@@ -301,6 +303,7 @@ import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import { IdCard } from 'lucide-vue-next'
|
||||
import api from '@/api'
|
||||
import { hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
|
||||
|
||||
const dialogOpen = defineModel({
|
||||
required: false,
|
||||
@@ -328,15 +331,20 @@ const handleEmojiSelect = (emoji) => {
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMediaFiles } =
|
||||
useFileUpload({
|
||||
linkedModel: 'messages'
|
||||
})
|
||||
const {
|
||||
uploadingFiles,
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
uploadFiles,
|
||||
mediaFiles,
|
||||
clearMediaFiles
|
||||
} = useFileUpload({
|
||||
linkedModel: 'messages'
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (loading.value || uploadingFiles.value.length > 0) {
|
||||
return true
|
||||
}
|
||||
if (loading.value || uploadingFiles.value.length > 0) return true
|
||||
if (hasPendingInlineUpload(form?.values?.content)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
class="h-full flex-grow"
|
||||
/>
|
||||
@@ -138,6 +139,7 @@
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
/>
|
||||
</div>
|
||||
@@ -178,6 +180,7 @@ import {
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { useEmitter } from '@main/composables/useEmitter'
|
||||
import { useFileUpload } from '@main/composables/useFileUpload'
|
||||
import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
|
||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import {
|
||||
@@ -208,6 +211,7 @@ const {
|
||||
uploadingFiles,
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
uploadFiles,
|
||||
mediaFiles,
|
||||
clearMediaFiles,
|
||||
setMediaFiles
|
||||
@@ -321,7 +325,9 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
|
||||
let hasMessageSendingErrored = false
|
||||
isEditorFullscreen.value = false
|
||||
|
||||
const hasContent = hasTextContent.value > 0 || mediaFiles.value.length > 0
|
||||
const html = htmlContent.value
|
||||
if (hasPendingInlineUpload(html)) return
|
||||
const hasContent = hasTextContent.value || hasInlineImage(html) || mediaFiles.value.length > 0
|
||||
const convUUID = conversationStore.current.uuid
|
||||
const isPrivate = messageType.value === 'private_note'
|
||||
|
||||
|
||||
@@ -96,10 +96,12 @@
|
||||
:autoFocus="true"
|
||||
:disabled="isDraftLoading"
|
||||
:enableMentions="messageType === 'private_note'"
|
||||
:enableInlineImages="conversationStore.current.inbox_channel === 'email'"
|
||||
:getSuggestions="getSuggestions"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
@send="handleSend"
|
||||
@mentionsChanged="handleMentionsChanged"
|
||||
@filesDropped="handleFilesDropped"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +114,7 @@
|
||||
/>
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview
|
||||
<ReplyBoxAttachmentPreview
|
||||
:attachments="uploadedFiles"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleOnFileDelete"
|
||||
@@ -139,12 +141,13 @@ import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
|
||||
import { MACRO_CONTEXT } from '@main/constants/conversation'
|
||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import Editor from '@main/components/editor/TextEditor.vue'
|
||||
import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
|
||||
import { useEmitter } from '@main/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
||||
import ReplyBoxAttachmentPreview from '@/features/conversation/message/attachment/ReplyBoxAttachmentPreview.vue'
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -239,6 +242,7 @@ const emit = defineEmits([
|
||||
'fileUpload',
|
||||
'inlineImageUpload',
|
||||
'fileDelete',
|
||||
'filesDropped',
|
||||
'aiPromptSelected'
|
||||
])
|
||||
|
||||
@@ -264,8 +268,11 @@ const toggleFullscreen = () => {
|
||||
}
|
||||
|
||||
const enableSend = computed(() => {
|
||||
const html = htmlContent.value
|
||||
return (
|
||||
!hasPendingInlineUpload(html) &&
|
||||
(textContent.value.trim().length > 0 ||
|
||||
hasInlineImage(html) ||
|
||||
conversationStore.getMacro('reply')?.actions?.length > 0 ||
|
||||
props.uploadedFiles.length > 0) &&
|
||||
emailErrors.value.length === 0 &&
|
||||
@@ -314,6 +321,10 @@ const handleFileUpload = (event) => {
|
||||
emit('fileUpload', event)
|
||||
}
|
||||
|
||||
const handleFilesDropped = (files) => {
|
||||
emit('filesDropped', files)
|
||||
}
|
||||
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
emit('fileDelete', uuid)
|
||||
}
|
||||
|
||||
@@ -60,12 +60,19 @@
|
||||
>
|
||||
{{ sanitizedContent }}
|
||||
</div>
|
||||
<Letter
|
||||
v-else
|
||||
:html="sanitizedContent"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html whitespace-pre-wrap break-words"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
<div v-else ref="messageContentEl" @click="onMessageContentClick">
|
||||
<Letter
|
||||
:html="sanitizedContent"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html whitespace-pre-wrap break-words"
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
v-model="inlineLightboxOpen"
|
||||
:images="inlineImages"
|
||||
:start-index="inlineLightboxIndex"
|
||||
/>
|
||||
|
||||
<!-- Quoted Text Toggle (incoming only) -->
|
||||
@@ -78,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
<BubbleAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
|
||||
<!-- CSAT Response -->
|
||||
<CSATResponseDisplay :message="message" />
|
||||
@@ -156,7 +163,8 @@ import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { formatMessageTimestamp, formatFullTimestamp } from '@shared-ui/utils/datetime.js'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import { Letter } from 'vue-letter'
|
||||
import MessageAttachmentPreview from '@main/features/conversation/message/attachment/MessageAttachmentPreview.vue'
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
import BubbleAttachmentPreview from '@main/features/conversation/message/attachment/BubbleAttachmentPreview.vue'
|
||||
import MessageEnvelope from './MessageEnvelope.vue'
|
||||
import CSATResponseDisplay from './CSATResponseDisplay.vue'
|
||||
import api from '@main/api'
|
||||
@@ -176,10 +184,8 @@ const userStore = useUserStore()
|
||||
const isSystemUser = computed(() => props.message.author?.email === 'System')
|
||||
const canManageUsers = computed(() => !isSystemUser.value && userStore.can('users:manage'))
|
||||
|
||||
// Direction helpers
|
||||
const isOutgoing = computed(() => props.direction === 'outgoing')
|
||||
|
||||
// Author info from message
|
||||
const getFullName = computed(() => {
|
||||
const author = props.message.author ?? {}
|
||||
const firstName = author.first_name ?? 'User'
|
||||
@@ -207,20 +213,16 @@ const nonInlineAttachments = computed(() =>
|
||||
props.message.attachments.filter((attachment) => attachment.disposition !== 'inline')
|
||||
)
|
||||
|
||||
// Bubble classes - conditional based on direction
|
||||
const bubbleClasses = computed(() => ({
|
||||
// Outgoing-specific: private message styling
|
||||
'bg-private': isOutgoing.value && props.message.private,
|
||||
'border border-border': isOutgoing.value && !props.message.private,
|
||||
'opacity-50 animate-pulse': isOutgoing.value && props.message.status === 'pending',
|
||||
'border-destructive': isOutgoing.value && props.message.status === 'failed',
|
||||
relative: isOutgoing.value,
|
||||
// Incoming-specific: quoted text visibility
|
||||
'show-quoted-text': !isOutgoing.value && showQuotedText.value,
|
||||
'hide-quoted-text': !isOutgoing.value && !showQuotedText.value
|
||||
}))
|
||||
|
||||
// Outgoing-only computed properties
|
||||
const isPrivateMessage = computed(() => isOutgoing.value && props.message.private)
|
||||
const showCheckCheck = computed(
|
||||
() => isOutgoing.value && props.message.status === 'sent' && !isPrivateMessage.value
|
||||
@@ -231,7 +233,6 @@ const retryMessage = (msg) => {
|
||||
api.retryMessage(convStore.current.uuid, msg.uuid)
|
||||
}
|
||||
|
||||
// Incoming-only: quoted text toggle
|
||||
const showQuotedText = ref(false)
|
||||
const hasQuotedContent = computed(
|
||||
() => !isOutgoing.value && sanitizedContent.value.includes('<blockquote')
|
||||
@@ -240,7 +241,44 @@ const toggleQuote = () => {
|
||||
showQuotedText.value = !showQuotedText.value
|
||||
}
|
||||
|
||||
// Envelope visibility (both directions)
|
||||
// Enumerate from rendered DOM (not HTML source) to inherit vue-letter's
|
||||
// sanitization and dodge regex parsing of attributes containing '>'.
|
||||
const messageContentEl = ref(null)
|
||||
const inlineLightboxOpen = ref(false)
|
||||
const inlineLightboxIndex = ref(0)
|
||||
const inlineImages = ref([])
|
||||
|
||||
// Re-walk on click instead of caching - cheaper than watching sanitizedContent
|
||||
// and always reflects what the user actually sees.
|
||||
const refreshInlineImages = () => {
|
||||
const root = messageContentEl.value
|
||||
if (!root) {
|
||||
inlineImages.value = []
|
||||
return
|
||||
}
|
||||
inlineImages.value = Array.from(root.querySelectorAll('img'))
|
||||
.map((el) => ({ url: el.getAttribute('src'), name: el.getAttribute('alt') || '' }))
|
||||
.filter((img) => img.url)
|
||||
}
|
||||
|
||||
const onMessageContentClick = (event) => {
|
||||
// closest('img') so clicks on <a><img></a> wrappers still resolve.
|
||||
const img = event.target?.closest?.('img')
|
||||
if (!img || !messageContentEl.value?.contains(img)) return
|
||||
|
||||
// Suppress anchor navigation so the lightbox can take over.
|
||||
const wrappingAnchor = img.closest('a')
|
||||
if (wrappingAnchor && messageContentEl.value.contains(wrappingAnchor)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
refreshInlineImages()
|
||||
const src = img.getAttribute('src')
|
||||
const idx = inlineImages.value.findIndex((entry) => entry.url === src)
|
||||
inlineLightboxIndex.value = idx >= 0 ? idx : 0
|
||||
inlineLightboxOpen.value = true
|
||||
}
|
||||
|
||||
const showEnvelope = computed(() => {
|
||||
return (
|
||||
props.message.meta?.from?.length ||
|
||||
@@ -250,4 +288,12 @@ const showEnvelope = computed(() => {
|
||||
props.message.meta?.subject
|
||||
)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.native-html :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="flex items-center group text-left">
|
||||
<Popover :open="showAudio" @update:open="showAudio = $event">
|
||||
<PopoverTrigger as-child>
|
||||
<div
|
||||
class="relative w-36 h-28 rounded border overflow-hidden cursor-pointer transition-colors"
|
||||
:class="
|
||||
isImage
|
||||
? ''
|
||||
: 'flex flex-col items-center justify-between bg-muted/40 hover:bg-muted p-3'
|
||||
"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="isImage">
|
||||
<img
|
||||
:src="getThumbFilepath(attachment.url)"
|
||||
:alt="attachment.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 top-0 flex items-start justify-between gap-2 px-2 pt-1.5 pb-5 bg-gradient-to-b from-black/75 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||
>
|
||||
<div class="min-w-0 flex-1 text-white image-meta">
|
||||
<p class="font-medium text-xs truncate">{{ shortName(attachment.name) }}</p>
|
||||
<p class="text-[10px] opacity-90">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
<DownloadLink
|
||||
:url="attachment.url"
|
||||
class="text-white hover:text-white hover:bg-white/15 shrink-0 pointer-events-auto -mr-0.5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<component :is="fileIcon" class="w-10 h-10" :class="iconColor" />
|
||||
</div>
|
||||
<div class="w-full text-center">
|
||||
<p class="text-xs font-medium text-foreground truncate" :title="attachment.name">
|
||||
{{ shortName(attachment.name) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DownloadLink
|
||||
v-if="!isImage"
|
||||
:url="attachment.url"
|
||||
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent v-if="isAudio" class="w-80 p-3" @click.stop>
|
||||
<p class="text-xs font-medium truncate mb-2" :title="attachment.name">
|
||||
{{ attachment.name }}
|
||||
</p>
|
||||
<audio :src="attachment.url" controls autoplay preload="auto" class="w-full h-8" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file'
|
||||
import DownloadLink from '@/components/DownloadLink.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
|
||||
import {
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
File,
|
||||
FileImage,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
FileAudio
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
attachment: { type: Object, required: true }
|
||||
})
|
||||
const emit = defineEmits(['preview'])
|
||||
|
||||
const showAudio = ref(false)
|
||||
|
||||
const shortName = (name) => (name || '').substring(0, 40)
|
||||
|
||||
const isImage = computed(() => (props.attachment.content_type || '').startsWith('image/'))
|
||||
|
||||
const isAudio = computed(() => (props.attachment.content_type || '').startsWith('audio/'))
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = (props.attachment.name || '').split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
if (isAudio.value) return FileAudio
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return FileText
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return FileSpreadsheet
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(e)) return FileImage
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return FileArchive
|
||||
if (['html', 'xml', 'json', 'js', 'css'].includes(e)) return FileCode
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const iconColor = computed(() => {
|
||||
if (isAudio.value) return 'text-purple-500'
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return 'text-red-500'
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return 'text-green-600'
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return 'text-blue-500'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return 'text-amber-600'
|
||||
return 'text-muted-foreground'
|
||||
})
|
||||
|
||||
const onClick = () => {
|
||||
if (isImage.value) {
|
||||
emit('preview', props.attachment)
|
||||
} else if (isAudio.value) {
|
||||
showAudio.value = true
|
||||
} else {
|
||||
window.open(props.attachment.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-meta {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="flex flex-row flex-wrap gap-2 break-all">
|
||||
<BubbleAttachmentItem
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.uuid"
|
||||
:attachment="attachment"
|
||||
@preview="openLightbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
v-model="lightboxOpen"
|
||||
:images="imageAttachments"
|
||||
:start-index="lightboxIndex"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BubbleAttachmentItem from '@/features/conversation/message/attachment/BubbleAttachmentItem.vue'
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
attachments: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const isImage = (attachment) => (attachment.content_type || '').startsWith('image/')
|
||||
|
||||
const imageAttachments = computed(() =>
|
||||
(props.attachments || []).filter(isImage)
|
||||
)
|
||||
|
||||
const lightboxOpen = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
|
||||
const openLightbox = (attachment) => {
|
||||
const idx = imageAttachments.value.findIndex((a) => a.uuid === attachment.uuid)
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0
|
||||
lightboxOpen.value = true
|
||||
}
|
||||
</script>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center group text-left">
|
||||
<div class="relative w-36 h-28 flex items-center justify-center">
|
||||
<div>
|
||||
<span class="size-20">📄</span>
|
||||
</div>
|
||||
<div class="p-1 absolute inset-0 text-gray-50 opacity-10 group-hover:opacity-100 overlay text-wrap">
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="font-bold text-xs opacity-80 group-hover:opacity-100">
|
||||
{{ getAttachmentName(attachment.name) }}
|
||||
</p>
|
||||
<p class="text-xs opacity-0 group-hover:opacity-100">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
<div @click="downloadAttachment">
|
||||
<Download size=20></Download>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatBytes } from '@shared-ui/utils/file'
|
||||
import { Download } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const getAttachmentName = (name) => {
|
||||
return (name || '').substring(0, 50)
|
||||
}
|
||||
|
||||
const downloadAttachment = () => {
|
||||
window.open(props.attachment.url, '_blank')
|
||||
}
|
||||
</script>
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center group text-left">
|
||||
<div class="relative">
|
||||
<img :src="getThumbFilepath(attachment.url)" class="w-36 h-28 flex items-center object-cover" />
|
||||
<div class="p-1 absolute inset-0 text-gray-50 opacity-0 group-hover:opacity-100 overlay text-wrap">
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="font-bold text-xs">{{ trimAttachmentName(attachment.name) }}</p>
|
||||
<p class="text-xs">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
<div @click="downloadAttachment">
|
||||
<Download size=20></Download>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file'
|
||||
import { Download } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const trimAttachmentName = (name) => {
|
||||
return (name || '').substring(0, 40)
|
||||
}
|
||||
|
||||
const downloadAttachment = () => {
|
||||
window.open(props.attachment.url, '_blank');
|
||||
}
|
||||
</script>
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row flex-wrap gap-2 break-all">
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.uuid"
|
||||
class="flex items-center cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<ImageAttachmentPreview v-if="isImage(attachment)" :attachment="attachment" />
|
||||
<FileAttachmentPreview v-else :attachment="attachment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImageAttachmentPreview from '@/features/conversation/message/attachment/ImageAttachmentPreview.vue'
|
||||
import FileAttachmentPreview from '@/features/conversation/message/attachment/FileAttachmentPreview.vue'
|
||||
|
||||
defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const isImage = (attachment) => {
|
||||
return attachment.content_type.includes('image')
|
||||
}
|
||||
</script>
|
||||
@@ -204,7 +204,7 @@ const openContextLink = async (app) => {
|
||||
try {
|
||||
loadingAppId.value = app.id
|
||||
const resp = await api.getContextLinkURL(app.id, uuid)
|
||||
window.open(resp.data.data, '_blank')
|
||||
window.open(resp.data.data, '_blank', 'noopener,noreferrer')
|
||||
} catch {
|
||||
// Silently ignore.
|
||||
} finally {
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
:key="idx"
|
||||
:href="page.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
class="block p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<a :href="announcement.url" target="_blank" class="block no-underline">
|
||||
<a :href="announcement.url" target="_blank" rel="noopener noreferrer" class="block no-underline">
|
||||
<Card class="overflow-hidden hover:bg-accent transition-colors cursor-pointer rounded-md">
|
||||
<img
|
||||
:src="announcement.image_url"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<a :href="link.url" target="_blank" class="block no-underline">
|
||||
<a :href="link.url" target="_blank" rel="noopener noreferrer" class="block no-underline">
|
||||
<Card class="hover:bg-accent transition-colors cursor-pointer rounded-md">
|
||||
<CardContent class="p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
|
||||
@@ -53,11 +53,11 @@ const getThumbnailUrl = (attachment) => {
|
||||
}
|
||||
|
||||
const openImage = (url) => {
|
||||
window.open(url, '_blank')
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const downloadFile = (attachment) => {
|
||||
window.open(attachment.url, '_blank')
|
||||
window.open(attachment.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const truncateFileName = (name) => {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<a
|
||||
href="https://libredesk.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-[10px] text-muted-foreground/70 hover:text-muted-foreground transition-colors no-underline"
|
||||
>
|
||||
Powered by <span class="font-medium">libredesk</span>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.4.37",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vue-i18n": "9",
|
||||
"vue-letter": "^0.2.0",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
|
||||
Generated
+13
@@ -125,6 +125,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.4.37
|
||||
version: 3.5.13(typescript@5.7.3)
|
||||
vue-easy-lightbox:
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0(vue@3.5.13(typescript@5.7.3))
|
||||
vue-i18n:
|
||||
specifier: '9'
|
||||
version: 9.14.5(vue@3.5.13(typescript@5.7.3))
|
||||
@@ -3803,6 +3806,12 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-easy-lightbox@1.19.0:
|
||||
resolution: {integrity: sha512-YxLXgjEn91UF3DuK1y8u3Pyx2sJ7a/MnBpkyrBSQkvU1glzEJASyAZ7N+5yDpmxBQDVMwCsL2VmxWGIiFrWCgA==}
|
||||
engines: {node: '>=14.18.3'}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-eslint-parser@9.4.3:
|
||||
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
|
||||
engines: {node: ^14.17.0 || >=16.0.0}
|
||||
@@ -7724,6 +7733,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
vue-easy-lightbox@1.19.0(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
vue-eslint-parser@9.4.3(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 4.4.0(supports-color@8.1.1)
|
||||
|
||||
@@ -11,3 +11,9 @@ export function getThumbFilepath (filepath) {
|
||||
const filename = urlParts.pop()
|
||||
return `/uploads/thumb_${filename}`
|
||||
}
|
||||
|
||||
export function downloadUrl (url) {
|
||||
if (!url) return url
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}download=1`
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
:href="appSettingsStore.settings['app.update'].update.url"
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer"
|
||||
rel="nofollow noopener noreferrer"
|
||||
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
>
|
||||
{{ appSettingsStore.settings['app.update'].update.release_version }}
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Baggrundsfarve",
|
||||
"globals.messages.backgroundImageUrl": "Baggrundsbillede-URL",
|
||||
"globals.messages.badRequest": "Dårlig anmodning",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "Blokér {name}",
|
||||
"globals.messages.cancel": "Afbryd",
|
||||
"globals.messages.caseSensitiveMatch": "Versalfølsom match",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Faldende",
|
||||
"globals.terms.description": "Beskrivelse | Beskrivelser",
|
||||
"globals.terms.disabled": "Deaktiveret",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Kladde",
|
||||
"globals.terms.email": "E-mail | E-mails",
|
||||
"globals.terms.enabled": "Aktiveret",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Åben",
|
||||
"globals.terms.openMenu": "Åbn menu",
|
||||
"globals.terms.optional": "Valgfri | Valgfrie",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "Forfalden",
|
||||
"globals.terms.overview": "Oversigt",
|
||||
"globals.terms.page": "Side | Sider",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Referencenummer",
|
||||
"globals.terms.regex": "Regex | Regex'er",
|
||||
"globals.terms.regexHint": "Regex-tip",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Svar | Svar",
|
||||
"globals.terms.report": "Rapport | Rapporter",
|
||||
"globals.terms.required": "Obligatorisk",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLA'er",
|
||||
"globals.terms.slaMetric": "SLA-metrik | SLA-metrikker",
|
||||
"globals.terms.slaPolicy": "SLA-politik | SLA-politikker",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "SMTP-vært | SMTP-værter",
|
||||
"globals.terms.smtpPort": "SMTP-port | SMTP-porte",
|
||||
"globals.terms.snooze": "Slumre",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "Hvid",
|
||||
"globals.terms.workspace": "Arbejdsområde",
|
||||
"globals.terms.you": "Dig",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Roller og teams skal matche nøjagtigt (versalfølsom)",
|
||||
"importer.createdAgent": "Række {row}: Agent {name} ({email}) oprettet",
|
||||
"importer.createdTag": "Række {row}: Tag \"{name}\" oprettet",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Hintergrundfarbe",
|
||||
"globals.messages.backgroundImageUrl": "Hintergrundbild-URL",
|
||||
"globals.messages.badRequest": "Ungültige Anfrage",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "{name} blockieren",
|
||||
"globals.messages.cancel": "Abbrechen",
|
||||
"globals.messages.caseSensitiveMatch": "Groß-/Kleinschreibung muss übereinstimmen",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Absteigend",
|
||||
"globals.terms.description": "Beschreibung | Beschreibungen",
|
||||
"globals.terms.disabled": "Deaktiviert",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Entwurf",
|
||||
"globals.terms.email": "E-Mail | E-Mails",
|
||||
"globals.terms.enabled": "Aktiviert",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Offen",
|
||||
"globals.terms.openMenu": "Menü öffnen",
|
||||
"globals.terms.optional": "Optional | Optional",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "Überfällig",
|
||||
"globals.terms.overview": "Übersicht",
|
||||
"globals.terms.page": "Seite | Seiten",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Referenznummer",
|
||||
"globals.terms.regex": "Regulärer Ausdruck | Reguläre Ausdrücke",
|
||||
"globals.terms.regexHint": "Regex-Hinweis",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Antwort | Antworten",
|
||||
"globals.terms.report": "Bericht | Berichte",
|
||||
"globals.terms.required": "Erforderlich",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLA",
|
||||
"globals.terms.slaMetric": "SLA-Metrik | SLA-Metriken",
|
||||
"globals.terms.slaPolicy": "SLA-Richtlinie | SLA-Richtlinien",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "SMTP-Host | SMTP-Hosts",
|
||||
"globals.terms.smtpPort": "SMTP-Port | SMTP-Ports",
|
||||
"globals.terms.snooze": "Später erinnern",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "Weiß",
|
||||
"globals.terms.workspace": "Arbeitsbereich",
|
||||
"globals.terms.you": "Sie",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Rollen und Teams müssen exakt übereinstimmen (groß-/kleinschreibungsempfindlich)",
|
||||
"importer.createdAgent": "Zeile {row}: Agent {name} ({email}) erstellt",
|
||||
"importer.createdTag": "Zeile {row}: Tag \"{name}\" erstellt",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Background color",
|
||||
"globals.messages.backgroundImageUrl": "Background image URL",
|
||||
"globals.messages.badRequest": "Bad request",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "Block",
|
||||
"globals.messages.cancel": "Cancel",
|
||||
"globals.messages.caseSensitiveMatch": "Case sensitive match",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Descending",
|
||||
"globals.terms.description": "Description | Descriptions",
|
||||
"globals.terms.disabled": "Disabled",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Draft",
|
||||
"globals.terms.email": "Email | Emails",
|
||||
"globals.terms.enabled": "Enabled",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Open",
|
||||
"globals.terms.openMenu": "Open menu",
|
||||
"globals.terms.optional": "Optional | Optionals",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "Overdue",
|
||||
"globals.terms.overview": "Overview",
|
||||
"globals.terms.page": "Page | Pages",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Reference number",
|
||||
"globals.terms.regex": "Regex | Regexes",
|
||||
"globals.terms.regexHint": "Regex hint",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Reply | Replies",
|
||||
"globals.terms.report": "Report | Reports",
|
||||
"globals.terms.required": "Required",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLAs",
|
||||
"globals.terms.slaMetric": "SLA metric | SLA metrics",
|
||||
"globals.terms.slaPolicy": "SLA policy | SLA policies",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "SMTP Host | SMTP Hosts",
|
||||
"globals.terms.smtpPort": "SMTP Port | SMTP Ports",
|
||||
"globals.terms.snooze": "Snooze",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "White",
|
||||
"globals.terms.workspace": "Workspace",
|
||||
"globals.terms.you": "You",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Roles and teams must match exactly (case-sensitive)",
|
||||
"importer.createdAgent": "Row {row}: Created agent {name} ({email})",
|
||||
"importer.createdTag": "Row {row}: Created tag \"{name}\"",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Color de fondo",
|
||||
"globals.messages.backgroundImageUrl": "URL de imagen de fondo",
|
||||
"globals.messages.badRequest": "Solicitud incorrecta",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "Bloquear {name}",
|
||||
"globals.messages.cancel": "Cancelar",
|
||||
"globals.messages.caseSensitiveMatch": "Coincidencia sensible a mayúsculas y minúsculas",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Descendente",
|
||||
"globals.terms.description": "Descripción | Descripciones",
|
||||
"globals.terms.disabled": "Deshabilitado",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Borrador",
|
||||
"globals.terms.email": "Correo Electrónico | Correos Electrónicos",
|
||||
"globals.terms.enabled": "Habilitado",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Abrir",
|
||||
"globals.terms.openMenu": "Abrir menú",
|
||||
"globals.terms.optional": "Opcional | Opcionales",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "Vencido",
|
||||
"globals.terms.overview": "Resumen",
|
||||
"globals.terms.page": "Página | Páginas",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Número de referencia",
|
||||
"globals.terms.regex": "Regex | Regexes",
|
||||
"globals.terms.regexHint": "Sugerencia de regex",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Respuesta | Respuestas",
|
||||
"globals.terms.report": "Informe | Informes",
|
||||
"globals.terms.required": "Requerido",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLAs",
|
||||
"globals.terms.slaMetric": "Métrica SLA | Métricas SLA",
|
||||
"globals.terms.slaPolicy": "Política SLA | Políticas SLA",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "Host SMTP | Hosts SMTP",
|
||||
"globals.terms.smtpPort": "Puerto SMTP | Puertos SMTP",
|
||||
"globals.terms.snooze": "Posponer",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "Blanco",
|
||||
"globals.terms.workspace": "Espacio de trabajo",
|
||||
"globals.terms.you": "Tú",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Los roles y equipos deben coincidir exactamente (sensible a mayúsculas y minúsculas)",
|
||||
"importer.createdAgent": "Fila {row}: Agente creado {name} ({email})",
|
||||
"importer.createdTag": "Fila {row}: Tag \"{name}\" creado",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "رنگ پسزمینه",
|
||||
"globals.messages.backgroundImageUrl": "آدرس تصویر پسزمینه",
|
||||
"globals.messages.badRequest": "درخواست نامعتبر",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "مسدود کردن {name}",
|
||||
"globals.messages.cancel": "لغو",
|
||||
"globals.messages.caseSensitiveMatch": "تطابق حساس به حروف",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "نزولی",
|
||||
"globals.terms.description": "توضیحات | توضیحات",
|
||||
"globals.terms.disabled": "غیرفعال",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "پیشنویس",
|
||||
"globals.terms.email": "ایمیل | ایمیلها",
|
||||
"globals.terms.enabled": "فعال",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "باز",
|
||||
"globals.terms.openMenu": "باز کردن منو",
|
||||
"globals.terms.optional": "اختیاری | اختیاری",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "سررسید گذشته",
|
||||
"globals.terms.overview": "نمای کلی",
|
||||
"globals.terms.page": "صفحه | صفحات",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "شماره مرجع",
|
||||
"globals.terms.regex": "عبارت منظم | عبارات منظم",
|
||||
"globals.terms.regexHint": "راهنمای عبارت منظم",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "پاسخ | پاسخها",
|
||||
"globals.terms.report": "گزارش | گزارشها",
|
||||
"globals.terms.required": "الزامی",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLAها",
|
||||
"globals.terms.slaMetric": "معیار SLA | معیارهای SLA",
|
||||
"globals.terms.slaPolicy": "سیاست SLA | سیاستهای SLA",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "میزبان SMTP | میزبانهای SMTP",
|
||||
"globals.terms.smtpPort": "پورت SMTP | پورتهای SMTP",
|
||||
"globals.terms.snooze": "تعویق",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "سفید",
|
||||
"globals.terms.workspace": "فضای کاری",
|
||||
"globals.terms.you": "شما",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "نقشها و تیمها باید دقیقاً مطابقت داشته باشند (حساس به حروف بزرگ و کوچک)",
|
||||
"importer.createdAgent": "ردیف {row}: نماینده {name} ({email}) ایجاد شد",
|
||||
"importer.createdTag": "ردیف {row}: برچسب \"{name}\" ایجاد شد",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Couleur d'arrière-plan",
|
||||
"globals.messages.backgroundImageUrl": "URL de l'image d'arrière-plan",
|
||||
"globals.messages.badRequest": "Requête invalide",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "Bloquer {name}",
|
||||
"globals.messages.cancel": "Annuler",
|
||||
"globals.messages.caseSensitiveMatch": "Correspondance sensible à la casse",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Décroissant",
|
||||
"globals.terms.description": "Description | Descriptions",
|
||||
"globals.terms.disabled": "Désactivé",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Brouillon",
|
||||
"globals.terms.email": "E-mail | E-mails",
|
||||
"globals.terms.enabled": "Activé",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Ouvert",
|
||||
"globals.terms.openMenu": "Ouvrir le menu",
|
||||
"globals.terms.optional": "Optionnel | Optionnels",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "En Retard",
|
||||
"globals.terms.overview": "Vue d'ensemble",
|
||||
"globals.terms.page": "Page | Pages",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Numéro de référence",
|
||||
"globals.terms.regex": "Regex | Regex",
|
||||
"globals.terms.regexHint": "Indice Regex",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Réponse | Réponses",
|
||||
"globals.terms.report": "Rapport | Rapports",
|
||||
"globals.terms.required": "Requis",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | SLA",
|
||||
"globals.terms.slaMetric": "Statistique SLA | Statistiques SLA",
|
||||
"globals.terms.slaPolicy": "Politique SLA | Politiques SLA",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "Hôte SMTP | Hôtes SMTP",
|
||||
"globals.terms.smtpPort": "Port SMTP | Ports SMTP",
|
||||
"globals.terms.snooze": "Reporter",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "Blanc",
|
||||
"globals.terms.workspace": "Espace de travail",
|
||||
"globals.terms.you": "Vous",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Les rôles et équipes doivent correspondre exactement (sensible à la casse)",
|
||||
"importer.createdAgent": "Ligne {row} : Agent {name} ({email}) créé",
|
||||
"importer.createdTag": "Ligne {row} : Tag \"{name}\" créé",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "Colore di sfondo",
|
||||
"globals.messages.backgroundImageUrl": "URL immagine di sfondo",
|
||||
"globals.messages.badRequest": "Richiesta sbagliata",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "Blocca {name}",
|
||||
"globals.messages.cancel": "Annulla",
|
||||
"globals.messages.caseSensitiveMatch": "Corrispondenza sensibile alle maiuscole",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "Decrescente",
|
||||
"globals.terms.description": "Descrizione | Descrizioni",
|
||||
"globals.terms.disabled": "Disattivato",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "Bozza",
|
||||
"globals.terms.email": "Email | Email",
|
||||
"globals.terms.enabled": "Attivo",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "Aperto",
|
||||
"globals.terms.openMenu": "Apri menu",
|
||||
"globals.terms.optional": "Opzionale | Opzionali",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "In ritardo",
|
||||
"globals.terms.overview": "Panoramica",
|
||||
"globals.terms.page": "Pagina | Pagine",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "Numero di riferimento",
|
||||
"globals.terms.regex": "Regex | Regex",
|
||||
"globals.terms.regexHint": "Suggerimento Regex",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "Risposta | Risposte",
|
||||
"globals.terms.report": "Relazione | Relazioni",
|
||||
"globals.terms.required": "Richiesto",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA | Contratti di servizio",
|
||||
"globals.terms.slaMetric": "Metriche di utilizzo | Metriche di utilizzo",
|
||||
"globals.terms.slaPolicy": "Politica SLA | Politiche SLA",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "Host SMTP | Hosts SMTP",
|
||||
"globals.terms.smtpPort": "Porta SMTP | Porta SMTP",
|
||||
"globals.terms.snooze": "Posticipa",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "Bianco",
|
||||
"globals.terms.workspace": "Area di lavoro",
|
||||
"globals.terms.you": "Tu",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "Ruoli e team devono corrispondere esattamente (distinzione tra maiuscole e minuscole)",
|
||||
"importer.createdAgent": "Riga {row}: Agente {name} ({email}) creato",
|
||||
"importer.createdTag": "Riga {row}: Tag \"{name}\" creato",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "背景色",
|
||||
"globals.messages.backgroundImageUrl": "背景画像URL",
|
||||
"globals.messages.badRequest": "不正なリクエスト",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "{name} をブロック",
|
||||
"globals.messages.cancel": "キャンセル",
|
||||
"globals.messages.caseSensitiveMatch": "大文字と小文字を区別する",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "降順",
|
||||
"globals.terms.description": "説明",
|
||||
"globals.terms.disabled": "無効",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "下書き",
|
||||
"globals.terms.email": "Eメール",
|
||||
"globals.terms.enabled": "有効",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "オープン",
|
||||
"globals.terms.openMenu": "メニューを開く",
|
||||
"globals.terms.optional": "オプション",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "期限超過",
|
||||
"globals.terms.overview": "概要",
|
||||
"globals.terms.page": "ページ",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "参照番号",
|
||||
"globals.terms.regex": "正規表現",
|
||||
"globals.terms.regexHint": "正規表現のヒント",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "返信",
|
||||
"globals.terms.report": "レポート",
|
||||
"globals.terms.required": "必須",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "SLA",
|
||||
"globals.terms.slaMetric": "SLA 指標",
|
||||
"globals.terms.slaPolicy": "SLAポリシー",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "SMTP ホスト",
|
||||
"globals.terms.smtpPort": "SMTP ポート",
|
||||
"globals.terms.snooze": "スヌーズ",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "白",
|
||||
"globals.terms.workspace": "ワークスペース",
|
||||
"globals.terms.you": "あなた",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "ロールとチームは正確に一致 (大文字と小文字を区別)する必要があります",
|
||||
"importer.createdAgent": "行 {row}: エージェント {name} ({email}) を作成しました",
|
||||
"importer.createdTag": "行 {row}: タグ「{name}」を作成しました",
|
||||
|
||||
@@ -543,6 +543,7 @@
|
||||
"globals.messages.backgroundColor": "बॅकग्राउंड रंग",
|
||||
"globals.messages.backgroundImageUrl": "बॅकग्राउंड इमेज URL",
|
||||
"globals.messages.badRequest": "चुकीची विनंती",
|
||||
"globals.messages.bestFit": "Best fit",
|
||||
"globals.messages.block": "{name} ब्लॉक करा",
|
||||
"globals.messages.cancel": "रद्द करा",
|
||||
"globals.messages.caseSensitiveMatch": "केस संवेदनशील जुळणी",
|
||||
@@ -688,6 +689,7 @@
|
||||
"globals.terms.descending": "उतरत्या क्रमाने",
|
||||
"globals.terms.description": "वर्णन | वर्णने",
|
||||
"globals.terms.disabled": "अक्षम",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.draft": "ड्राफ्ट",
|
||||
"globals.terms.email": "ईमेल | ईमेल",
|
||||
"globals.terms.enabled": "सक्षम",
|
||||
@@ -752,6 +754,7 @@
|
||||
"globals.terms.open": "उघडे",
|
||||
"globals.terms.openMenu": "मेनू उघडा",
|
||||
"globals.terms.optional": "पर्यायी | पर्यायी",
|
||||
"globals.terms.original": "Original",
|
||||
"globals.terms.overdue": "ओव्हरड्यू",
|
||||
"globals.terms.overview": "आढावा",
|
||||
"globals.terms.page": "पृष्ठ | पृष्ठे",
|
||||
@@ -774,6 +777,7 @@
|
||||
"globals.terms.referenceNumber": "संदर्भ क्रमांक",
|
||||
"globals.terms.regex": "रेगेक्स | रेगेक्स",
|
||||
"globals.terms.regexHint": "रेगेक्स संकेत",
|
||||
"globals.terms.remove": "Remove",
|
||||
"globals.terms.reply": "उत्तर | उत्तरे",
|
||||
"globals.terms.report": "रिपोर्ट | रिपोर्ट",
|
||||
"globals.terms.required": "आवश्यक",
|
||||
@@ -793,6 +797,7 @@
|
||||
"globals.terms.sla": "एसएलए | एसएलए",
|
||||
"globals.terms.slaMetric": "SLA मेट्रिक | SLA मेट्रिक्स",
|
||||
"globals.terms.slaPolicy": "SLA धोरण | SLA धोरणे",
|
||||
"globals.terms.small": "Small",
|
||||
"globals.terms.smtpHost": "SMTP होस्ट | SMTP होस्ट्स",
|
||||
"globals.terms.smtpPort": "SMTP पोर्ट | SMTP पोर्ट्स",
|
||||
"globals.terms.snooze": "स्नूझ",
|
||||
@@ -837,6 +842,11 @@
|
||||
"globals.terms.white": "पांढरा",
|
||||
"globals.terms.workspace": "वर्कस्पेस",
|
||||
"globals.terms.you": "तुम्ही",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
"imageLightbox.zoomIn": "Zoom in",
|
||||
"imageLightbox.zoomOut": "Zoom out",
|
||||
"importer.agentCaseSensitiveNote": "भूमिका आणि संघ नक्की जुळणे आवश्यक आहे (केस-सेन्सिटिव्ह)",
|
||||
"importer.createdAgent": "पंक्ती {row}: एजंट {name} ({email}) तयार केला",
|
||||
"importer.createdTag": "पंक्ती {row}: \"{name}\" टॅग तयार केला",
|
||||
|
||||
@@ -133,10 +133,12 @@ type userStore interface {
|
||||
}
|
||||
|
||||
type mediaStore interface {
|
||||
Get(id int, uuid string) (mmodels.Media, error)
|
||||
GetBlob(name string) ([]byte, error)
|
||||
GetURL(uuid, contentType, fileName string) string
|
||||
GetSignedURL(name string) string
|
||||
Attach(id int, model string, modelID int) error
|
||||
SetContentID(id int, contentID string) error
|
||||
GetByModel(id int, model string) ([]mmodels.Media, error)
|
||||
ContentIDExists(contentID string) (bool, string, error)
|
||||
Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -34,6 +35,15 @@ const (
|
||||
upgradeWindowTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// For <img class="inline-image" src="/uploads/abc-123?sig=xyz">:
|
||||
//
|
||||
// group 1 = `<img class="inline-image" src="`
|
||||
// group 2 = `abc-123` (media UUID)
|
||||
// group 3 = `"`
|
||||
var imgSrcUploadsPattern = regexp.MustCompile(
|
||||
`(?i)(<img\b[^>]*?\bsrc=["'])(?:https?://[^"'<>\s/]+)?/uploads/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\?[^"'<>\s]*)?(["'])`,
|
||||
)
|
||||
|
||||
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
|
||||
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent.
|
||||
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
|
||||
@@ -535,6 +545,13 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
message.ContentType = models.ContentTypeText
|
||||
}
|
||||
|
||||
// Extract inline media UUIDs for linking after message insertion.
|
||||
inlineUUIDs := extractInlineImageUUIDs(message.Content)
|
||||
|
||||
// Rewrite inline image URLs in the message content to use CID references.
|
||||
// The GET conversation messages API rewrites the CID references back to valid signed urls.
|
||||
message.Content = rewriteInlineImagesToCID(message.Content)
|
||||
|
||||
// Convert content to plain text for search.
|
||||
if message.ContentType == models.ContentTypeText {
|
||||
message.TextContent = message.Content
|
||||
@@ -554,6 +571,9 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
m.mediaStore.Attach(media.ID, mmodels.ModelMessages, message.ID)
|
||||
}
|
||||
|
||||
// Link inline media and stamp content_id so the cid: form just persisted resolves on read.
|
||||
m.linkInlineMediaToMessage(inlineUUIDs, message.ID)
|
||||
|
||||
// Add this user as a participant if not already present.
|
||||
m.addConversationParticipant(message.SenderID, message.ConversationUUID)
|
||||
|
||||
@@ -971,6 +991,70 @@ func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, p
|
||||
return sqlQuery, pageSize, qArgs, nil
|
||||
}
|
||||
|
||||
// extractInlineImageUUIDs returns the unique media UUIDs referenced by
|
||||
// <img src=".../uploads/<uuid>"> in the body, in order of first appearance.
|
||||
func extractInlineImageUUIDs(content string) []string {
|
||||
matches := imgSrcUploadsPattern.FindAllStringSubmatch(content, -1)
|
||||
seen := make(map[string]bool, len(matches))
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, sub := range matches {
|
||||
if len(sub) < 3 {
|
||||
continue
|
||||
}
|
||||
if seen[sub[2]] {
|
||||
continue
|
||||
}
|
||||
seen[sub[2]] = true
|
||||
out = append(out, sub[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rewriteInlineImagesToCID replaces every <img src=".../uploads/<uuid>"> with
|
||||
// <img src="cid:ldsk-<uuid>">.
|
||||
func rewriteInlineImagesToCID(content string) string {
|
||||
return imgSrcUploadsPattern.ReplaceAllStringFunc(content, func(match string) string {
|
||||
sub := imgSrcUploadsPattern.FindStringSubmatch(match)
|
||||
if len(sub) < 4 {
|
||||
return match
|
||||
}
|
||||
return sub[1] + "cid:" + inlineContentID(sub[2]) + sub[3]
|
||||
})
|
||||
}
|
||||
|
||||
// linkInlineMediaToMessage attaches each inline-image media row to this
|
||||
// message (so it isn't garbage-collected as an orphan) and stamps a stable
|
||||
// content_id so cid:ldsk-<uuid> in the saved body resolves on read.
|
||||
func (m *Manager) linkInlineMediaToMessage(uuids []string, messageID int) {
|
||||
for _, uuid := range uuids {
|
||||
media, err := m.mediaStore.Get(0, uuid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if media.Model.String != mmodels.ModelMessages {
|
||||
continue
|
||||
}
|
||||
// Linked to a different message already, leave it.
|
||||
if media.ModelID.Valid && media.ModelID.Int != messageID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Attach.
|
||||
if !media.ModelID.Valid {
|
||||
if err := m.mediaStore.Attach(media.ID, mmodels.ModelMessages, messageID); err != nil {
|
||||
m.lo.Warn("error linking inline media to message", "uuid", uuid, "message_id", messageID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set content_id if not already set.
|
||||
if media.ContentID == "" {
|
||||
if err := m.mediaStore.SetContentID(media.ID, inlineContentID(uuid)); err != nil {
|
||||
m.lo.Warn("error setting media content_id", "uuid", uuid, "message_id", messageID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uploadMessageAttachments uploads all attachments for a message.
|
||||
func (m *Manager) uploadMessageAttachments(message *models.Message) error {
|
||||
if len(message.Attachments) == 0 {
|
||||
@@ -1027,13 +1111,14 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
|
||||
return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err)
|
||||
}
|
||||
|
||||
// If the attachment is an image, generate and upload a thumbnail. Log any errors and continue, as thumbnail generation failure should not block message processing.
|
||||
// If the attachment is an image, generate and upload a thumbnail. Log any errors and continue.
|
||||
attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".")
|
||||
if slices.Contains(image.Exts, attachmentExt) {
|
||||
if slices.Contains(image.Exts, attachmentExt) || image.IsImageByContent(bytes.NewReader(attachment.Content)) {
|
||||
if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil {
|
||||
m.lo.Error("error uploading thumbnail", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
message.Media = append(message.Media, media)
|
||||
}
|
||||
return nil
|
||||
@@ -1103,30 +1188,36 @@ func (m *Manager) messageExistsBySourceID(messageSourceIDs []string) (int, error
|
||||
return conversationID, nil
|
||||
}
|
||||
|
||||
// fetchMessageAttachments fetches attachments for a single message ID - extracted for reuse
|
||||
// fetchMessageAttachments fetches attachments (also inline images) for a single message ID.
|
||||
func (m *Manager) fetchMessageAttachments(messageID int) (attachment.Attachments, error) {
|
||||
var attachments attachment.Attachments
|
||||
|
||||
// Get all media for this message
|
||||
// Get all media for this message.
|
||||
medias, err := m.mediaStore.GetByModel(messageID, mmodels.ModelMessages)
|
||||
if err != nil {
|
||||
return attachments, fmt.Errorf("error fetching message attachments: %w", err)
|
||||
}
|
||||
|
||||
// Fetch blobs for each media item
|
||||
// Fetch blobs for each media item.
|
||||
for _, media := range medias {
|
||||
blob, err := m.mediaStore.GetBlob(media.UUID)
|
||||
if err != nil {
|
||||
return attachments, fmt.Errorf("error fetching media blob: %w", err)
|
||||
}
|
||||
|
||||
contentID := media.ContentID
|
||||
if contentID == "" {
|
||||
contentID = media.UUID
|
||||
}
|
||||
|
||||
attachment := attachment.Attachment{
|
||||
Name: media.Filename,
|
||||
UUID: media.UUID,
|
||||
ContentType: media.ContentType,
|
||||
ContentID: contentID,
|
||||
Content: blob,
|
||||
Size: media.Size,
|
||||
Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String),
|
||||
Header: attachment.MakeHeader(media.ContentType, contentID, media.Filename, "base64", media.Disposition.String),
|
||||
URL: m.mediaStore.GetSignedURL(media.UUID),
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
@@ -1298,3 +1389,7 @@ func (m *Manager) getMediaPreview(media mmodels.Media) string {
|
||||
return m.i18n.T("globals.terms.file")
|
||||
}
|
||||
}
|
||||
|
||||
func inlineContentID(uuid string) string {
|
||||
return "ldsk-" + uuid
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testUUID = "abcdef01-2345-6789-abcd-ef0123456789"
|
||||
const testUUID2 = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
func TestImgSrcUploadsPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCount int
|
||||
wantUUIDs []string
|
||||
}{
|
||||
// Happy paths.
|
||||
{
|
||||
name: "relative_url",
|
||||
body: `<img src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "absolute_url",
|
||||
body: `<img src="https://libredesk.example.com/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "absolute_url_with_port",
|
||||
body: `<img src="http://localhost:9000/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "with_query_string",
|
||||
body: `<img src="/uploads/` + testUUID + `?sig=abc&exp=123">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "with_html_entity_query",
|
||||
body: `<img src="/uploads/` + testUUID + `?sig=abc&exp=123">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "single_quotes",
|
||||
body: `<img src='/uploads/` + testUUID + `'>`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "attrs_before_src",
|
||||
body: `<img class="inline-image" alt="x" data-foo="y" src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "attrs_after_src",
|
||||
body: `<img src="/uploads/` + testUUID + `" class="x" alt="y">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "xhtml_self_closing",
|
||||
body: `<img src="/uploads/` + testUUID + `" />`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "uppercase_img_tag",
|
||||
body: `<IMG SRC="/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "multiline_tag",
|
||||
body: "<img\n alt=\"x\"\n src=\"/uploads/" + testUUID + "\"\n>",
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "multiple_in_body",
|
||||
body: `hello <img src="/uploads/` + testUUID + `"> world ` +
|
||||
`<img src="/uploads/` + testUUID2 + `">`,
|
||||
wantCount: 2,
|
||||
wantUUIDs: []string{testUUID, testUUID2},
|
||||
},
|
||||
|
||||
// (?i) makes hex class case-insensitive too.
|
||||
{
|
||||
name: "quirk_uppercase_hex_uuid_matches",
|
||||
body: `<img src="/uploads/ABCDEF01-2345-6789-ABCD-EF0123456789">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{"ABCDEF01-2345-6789-ABCD-EF0123456789"},
|
||||
},
|
||||
// `\b` boundary lets data-src match; harmless, no real src to render.
|
||||
{
|
||||
name: "quirk_data_src_attribute_matches",
|
||||
body: `<img alt="x" data-src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
// Not context-aware: comments are matched too.
|
||||
{
|
||||
name: "quirk_inside_html_comment_matches",
|
||||
body: `<!-- <img src="/uploads/` + testUUID + `"> -->`,
|
||||
wantCount: 1,
|
||||
wantUUIDs: []string{testUUID},
|
||||
},
|
||||
|
||||
// Non-matches.
|
||||
{
|
||||
name: "anchor_href_no_match",
|
||||
body: `<a href="/uploads/` + testUUID + `">link</a>`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "picture_source_no_match",
|
||||
body: `<picture><source srcset="/uploads/` + testUUID + `"></picture>`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "malformed_uuid_no_match",
|
||||
body: `<img src="/uploads/not-a-uuid">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "uploads_filename_no_uuid_no_match",
|
||||
body: `<img src="/uploads/photo.png">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "uuid_too_short_no_match",
|
||||
body: `<img src="/uploads/abcdef01-2345-6789-abcd-ef0123">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "trailing_path_segment_no_match",
|
||||
body: `<img src="/uploads/` + testUUID + `/extra">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty_src_no_match",
|
||||
body: `<img src="">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := imgSrcUploadsPattern.FindAllStringSubmatch(tt.body, -1)
|
||||
if len(matches) != tt.wantCount {
|
||||
t.Fatalf("match count = %d, want %d (matches=%v)", len(matches), tt.wantCount, matches)
|
||||
}
|
||||
for i, want := range tt.wantUUIDs {
|
||||
if matches[i][2] != want {
|
||||
t.Errorf("match %d uuid = %q, want %q", i, matches[i][2], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestImgSrcUploadsPattern_Adversarial(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "image_element_should_not_match",
|
||||
body: `<image src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "imgblah_tag_should_not_match",
|
||||
body: `<imgblah src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "src_keyword_inside_alt_value_should_not_match",
|
||||
body: `<img alt="see src=/uploads/foo" data-foo="bar">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "input_element_should_not_match",
|
||||
body: `<input src="/uploads/` + testUUID + `">`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "multiline_img_src_should_match",
|
||||
body: "<img\n\tsrc=\"/uploads/" + testUUID + "\"\n>",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "extra_trailing_attributes_should_match",
|
||||
body: `<img src="/uploads/` + testUUID + `" width="100" height="50" loading="lazy">`,
|
||||
wantCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := imgSrcUploadsPattern.FindAllStringSubmatch(tt.body, -1)
|
||||
if len(matches) != tt.wantCount {
|
||||
t.Errorf("got %d matches, want %d\nbody: %s\nmatches: %v",
|
||||
len(matches), tt.wantCount, tt.body, matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractInlineImageUUIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty_body",
|
||||
body: "",
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "no_images",
|
||||
body: "Just some text, no images here.",
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single_image",
|
||||
body: `<img src="/uploads/` + testUUID + `">`,
|
||||
want: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "two_distinct_images",
|
||||
body: `<img src="/uploads/` + testUUID + `"><img src="/uploads/` + testUUID2 + `">`,
|
||||
want: []string{testUUID, testUUID2},
|
||||
},
|
||||
{
|
||||
name: "duplicate_uuid_deduped",
|
||||
body: `<img src="/uploads/` + testUUID + `"><img src="/uploads/` + testUUID + `?v=2">`,
|
||||
want: []string{testUUID},
|
||||
},
|
||||
{
|
||||
name: "ignores_cid_form",
|
||||
body: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractInlineImageUUIDs(tt.body)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("index %d: got %q, want %q", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteInlineImagesToCID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty_body",
|
||||
body: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no_change_when_no_uploads",
|
||||
body: `<p>hello world</p>`,
|
||||
want: `<p>hello world</p>`,
|
||||
},
|
||||
{
|
||||
name: "single_relative",
|
||||
body: `<img src="/uploads/` + testUUID + `">`,
|
||||
want: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
},
|
||||
{
|
||||
name: "absolute_url_with_query",
|
||||
body: `<img src="https://host.example.com/uploads/` + testUUID + `?sig=abc&exp=1">`,
|
||||
want: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
},
|
||||
{
|
||||
name: "preserves_other_attributes",
|
||||
body: `<img class="inline-image" alt="hi" src="/uploads/` + testUUID + `">`,
|
||||
want: `<img class="inline-image" alt="hi" src="cid:ldsk-` + testUUID + `">`,
|
||||
},
|
||||
{
|
||||
name: "preserves_single_quotes",
|
||||
body: `<img src='/uploads/` + testUUID + `'>`,
|
||||
want: `<img src='cid:ldsk-` + testUUID + `'>`,
|
||||
},
|
||||
{
|
||||
name: "rewrites_multiple",
|
||||
body: `<img src="/uploads/` + testUUID + `"><img src="/uploads/` + testUUID2 + `">`,
|
||||
want: `<img src="cid:ldsk-` + testUUID + `"><img src="cid:ldsk-` + testUUID2 + `">`,
|
||||
},
|
||||
{
|
||||
name: "leaves_cid_form_alone",
|
||||
body: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
want: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
},
|
||||
{
|
||||
name: "leaves_non_uploads_alone",
|
||||
body: `<a href="/uploads/` + testUUID + `">link</a>`,
|
||||
want: `<a href="/uploads/` + testUUID + `">link</a>`,
|
||||
},
|
||||
{
|
||||
name: "is_idempotent",
|
||||
body: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
want: `<img src="cid:ldsk-` + testUUID + `">`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := rewriteInlineImagesToCID(tt.body)
|
||||
if got != tt.want {
|
||||
t.Errorf("\n got: %s\nwant: %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Round-trip: extract from URL form, rewrite, then extract again should
|
||||
// produce zero URL-form matches (only cid-form references remain).
|
||||
t.Run("round_trip_url_to_cid", func(t *testing.T) {
|
||||
body := `<img src="/uploads/` + testUUID + `">`
|
||||
rewritten := rewriteInlineImagesToCID(body)
|
||||
if strings.Contains(rewritten, "/uploads/") {
|
||||
t.Errorf("rewritten body still contains /uploads/: %s", rewritten)
|
||||
}
|
||||
leftover := extractInlineImageUUIDs(rewritten)
|
||||
if len(leftover) != 0 {
|
||||
t.Errorf("expected 0 URL-form UUIDs after rewrite, got %v", leftover)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -15,6 +16,26 @@ var (
|
||||
ThumbPrefix = "thumb_"
|
||||
)
|
||||
|
||||
// IsImageByContent returns true when the file's magic bytes identify it as one
|
||||
// of the raster formats this package can decode. Used as a fallback when the
|
||||
// filename has no extension or an unreliable one (e.g. attachments arriving
|
||||
// through email without proper file extensions).
|
||||
func IsImageByContent(r io.ReadSeeker) bool {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return false
|
||||
}
|
||||
defer r.Seek(0, io.SeekStart)
|
||||
mtype, err := mimetype.DetectReader(r)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch mtype.String() {
|
||||
case "image/png", "image/jpeg", "image/gif":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDimensions returns the width and height of the image in the provided file.
|
||||
// It returns an error if the image cannot be decoded.
|
||||
func GetDimensions(r io.Reader) (int, int, error) {
|
||||
|
||||
@@ -88,6 +88,7 @@ type queries struct {
|
||||
GetByModel *sqlx.Stmt `query:"get-model-media"`
|
||||
GetUnlinkedMessageMedia *sqlx.Stmt `query:"get-unlinked-message-media"`
|
||||
ContentIDExists *sqlx.Stmt `query:"content-id-exists"`
|
||||
SetContentID *sqlx.Stmt `query:"set-media-content-id"`
|
||||
}
|
||||
|
||||
// UploadAndInsert uploads file on storage and inserts an entry in db.
|
||||
@@ -168,6 +169,15 @@ func (m *Manager) Get(id int, uuid string) (models.Media, error) {
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// SetContentID stamps a content_id onto a media row if one isn't already set.
|
||||
func (m *Manager) SetContentID(id int, contentID string) error {
|
||||
if _, err := m.queries.SetContentID.Exec(id, contentID); err != nil {
|
||||
m.lo.Error("error setting media content_id", "id", id, "content_id", contentID, "error", err)
|
||||
return fmt.Errorf("setting media content_id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContentIDExists checks if a content_id exists in the database and returns the UUID of the media file.
|
||||
func (m *Manager) ContentIDExists(contentID string) (bool, string, error) {
|
||||
var uuid string
|
||||
@@ -199,6 +209,10 @@ func (m *Manager) GetURL(uuid, contentType, fileName string) string {
|
||||
return m.store.GetURL(uuid, disposition, fileName)
|
||||
}
|
||||
|
||||
func (m *Manager) GetURLForDownload(uuid, fileName string) string {
|
||||
return m.store.GetURL(uuid, "attachment", fileName)
|
||||
}
|
||||
|
||||
// GetSignedURL generates a signed URL for secure media access if the store supports it.
|
||||
// Returns a regular URL if the store doesn't support signed URLs.
|
||||
func (m *Manager) GetSignedURL(name string) string {
|
||||
|
||||
@@ -51,4 +51,10 @@ WHERE model_type = 'messages'
|
||||
AND created_at < NOW() - INTERVAL '1 day';
|
||||
|
||||
-- name: content-id-exists
|
||||
SELECT uuid FROM media WHERE content_id = $1;
|
||||
SELECT uuid FROM media WHERE content_id = $1;
|
||||
|
||||
-- name: set-media-content-id
|
||||
UPDATE media
|
||||
SET content_id = $2
|
||||
WHERE id = $1
|
||||
AND (content_id IS NULL OR content_id = '');
|
||||
Reference in New Issue
Block a user