Merge pull request #289 from mageaustralia/feat/image-attachment-ux

Inline images, lightbox, and attachment UX
This commit is contained in:
Abhinav Raut
2026-05-14 17:12:30 +05:30
committed by GitHub
45 changed files with 1893 additions and 205 deletions
+13 -6
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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 {
@@ -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>
+1
View File
@@ -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",
+13
View File
@@ -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)
+6
View File
@@ -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 }}
+10
View File
@@ -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",
+10
View File
@@ -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",
+10
View File
@@ -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}\"",
+10
View File
@@ -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",
+10
View File
@@ -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}\" ایجاد شد",
+10
View File
@@ -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éé",
+10
View File
@@ -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",
+10
View File
@@ -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}」を作成しました",
+10
View File
@@ -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}\" टॅग तयार केला",
+2
View File
@@ -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)
+101 -6
View File
@@ -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
}
+351
View File
@@ -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&amp;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)
}
})
}
+21
View File
@@ -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) {
+14
View File
@@ -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 {
+7 -1
View File
@@ -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 = '');