Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5410,6 +5410,12 @@ function buildApplicationMenu() {
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
// ⌘⇧V is only wired up by this item existing: an accelerator with no menu
// entry is never translated into an editor command, so the chord was a
// no-op in every input in the app. The composer inserts plain text on
// every paste anyway, so this is the same result as ⌘V there — it's the
// terminal, preview, and other editable surfaces that need the strip.
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' }
]
Expand Down
18 changes: 8 additions & 10 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,18 +446,12 @@ export function ChatBar({
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)

if (imageBlobs.length > 0) {
event.preventDefault()

if (onAttachImageBlob) {
triggerHaptic('selection')
if (imageBlobs.length > 0 && onAttachImageBlob) {
triggerHaptic('selection')

for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}
for (const blob of imageBlobs) {
void onAttachImageBlob(blob)
}

return
}

// Trim surrounding whitespace so a copy that dragged along leading/trailing
Expand All @@ -469,6 +463,10 @@ export function ChatBar({
if (!pastedText) {
event.preventDefault()

if (imageBlobs.length > 0) {
return
}

// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
// the Linux clipboard the DOM paste event reads, so a host screenshot
// arrives as an empty paste (no blobs, no text). Fall back to the main
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/app/chat/composer/text-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,48 @@ describe('extractClipboardImageBlobs', () => {

expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
})

// A rich-text copy (Discord thread, web page, doc) carries prose plus whatever
// inline images the page decorated it with. That is a TEXT paste: attaching the
// page's placeholder graphics as composer images while the text vanished is the
// "blank attachments, no message" bug.
it('ignores inline HTML images when the copy carries its own text', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html'
? `<p>hello from the thread</p><img src="data:image/png;base64,${'A'.repeat(20_000)}">`
: 'hello from the thread',
items: []
} as unknown as DataTransfer

expect(extractClipboardImageBlobs(clipboard)).toEqual([])
})

it('keeps inline HTML images when the copy is image-only', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(20_000)}">` : '',
items: []
} as unknown as DataTransfer

const blobs = extractClipboardImageBlobs(clipboard)

expect(blobs).toHaveLength(1)
expect(blobs[0]?.type).toBe('image/png')
})

it('drops sub-thumbnail inline images — spacers, trackers, blurhash placeholders', () => {
const clipboard = {
files: { length: 0, item: () => null },
getData: (type: string) =>
type === 'text/html' ? `<img src="data:image/png;base64,${'A'.repeat(64)}">` : '',
items: []
} as unknown as DataTransfer

expect(extractClipboardImageBlobs(clipboard)).toEqual([])
})
})

describe('blobDedupeKey', () => {
Expand Down
27 changes: 19 additions & 8 deletions apps/desktop/src/app/chat/composer/text-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
// `:` or `:D` smiley doesn't open a popover the user didn't ask for.
const EMOJI_TRIGGER_RE = /(?:^|[\s\uFFFC])(:)([a-zA-Z0-9_+-]{2,})$/

const INLINE_IMAGE_SRC_RE = /<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi
// Below this, an inline data URL is chrome rather than content — a spacer, a
// 1×1 tracker, or a blurhash placeholder. Real pasted artwork clears it easily.
const MIN_INLINE_IMAGE_BYTES = 4096

/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
if (blob instanceof File) {
Expand Down Expand Up @@ -125,16 +130,22 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {

if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
}

if (blobs.length === 0) {
const html = clipboard.getData('text/html')

if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
return blobs
}

for (const match of matches) {
push(dataUrlToBlob(match[1]))
// Inline `<img src="data:…">` in the clipboard's HTML — but only for a copy
// that carried no text of its own. A rich-text copy WITH prose is a text
// paste that happens to contain images, and its data URLs are the page's
// decorations rather than content: Discord ships a 32×5 blurhash placeholder
// beside every image embed, so copying a thread attached a blank thumbnail
// and (because an image paste swallows the event) dropped the text entirely.
if (!text) {
for (const match of clipboard.getData('text/html').matchAll(INLINE_IMAGE_SRC_RE)) {
const blob = dataUrlToBlob(match[1])

if (blob && blob.size >= MIN_INLINE_IMAGE_BYTES) {
push(blob)
}
}
}
Expand Down
Loading