DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

Uploading PDF, Word and Excel Files in a Firebase Chat: Cloudinary resource_type=raw and Custom Bubbles

I have a chat built into a control panel for a sales team — orders, shifts, OTP signing, the works. It already handled text, images (via ImgBB), and voice messages (Cloudinary, resource_type=video). The one thing missing was generic document sending: PDF, Word, Excel files that operators need to share with each other.

The plan was to reuse the exact pattern already working for voice messages: upload to Cloudinary, save the URL to Firebase Realtime Database, render a bubble on the client. Should have been a straightforward extension. It mostly was — except for one detail that Cloudinary doesn't make obvious.

The 5 implementation steps

  1. CSS — document bubble. Translucent blue background, file-type icon, truncated filename, download button.
  2. File input + button in the "+" menu. An <input id="chatDocInput"> accepting PDF, Word, Excel, PPT, TXT, CSV, ZIP, and RAR, with a "📎 Document" entry in the contextual menu.
  3. Upload to Cloudinary with resource_type=raw. Binary files can't go under image or video — more on this below.
  4. Bubble render in _buildMsgEl(). A dynamic icon per file type, filename, size in KB/MB, click to open in a new tab.
  5. Chat list preview update. DM and group previews show 📎 FileName as the last message, consistent with images and voice.

The key code

Upload to Cloudinary — resource_type: raw

The critical bit: Cloudinary won't accept generic binary files under the image type. You have to explicitly declare resource_type=raw in the upload URL, using the same unsigned preset already configured for voice messages.

async function chatHandleDocFile(file) {
  const fd = new FormData();
  fd.append('file', file);
  fd.append('upload_preset', 'Vocali'); // existing unsigned preset

  // resource_type=raw — required for non-media files
  const res = await fetch(
    `https://api.cloudinary.com/v1_1/CLOUD_NAME/raw/upload`,
    { method: 'POST', body: fd }
  );
  const data = await res.json();
  return data.secure_url;
}
Enter fullscreen mode Exit fullscreen mode

Write to Firebase — type: 'document'

The message is written with a distinct type so the client knows how to render it. I also save fileName and fileSize at write time, so the bubble can render instantly without a re-fetch.

async function _sendChatDoc(docUrl, fileName, fileSize) {
  const msgData = {
    type:     'document',
    docUrl,
    fileName,
    fileSize, // e.g. "245 KB"
    sender:   currentUser.uid,
    ts:       Date.now(),
  };
  // push to /messages/{chatId}/ — same pattern as text and images
  await db.ref(`messages/${chatId}`).push(msgData);
}
Enter fullscreen mode Exit fullscreen mode

Bubble render — icon by file type

In _buildMsgEl() I add a branch for type === 'document'. The icon changes based on the extension, so the recipient immediately sees what kind of file is arriving.

function getDocIcon(fileName) {
  const ext = fileName.split('.').pop().toLowerCase();
  const icons = {
    pdf: '📄', doc: '📝', docx: '📝',
    xls: '📊', xlsx: '📊',
    ppt: '📑', pptx: '📑',
    zip: '🗜️', rar: '🗜️', '7z': '🗜️',
    txt: '📃', csv: '📃',
  };
  return icons[ext] ?? '📎';
}
Enter fullscreen mode Exit fullscreen mode

Problems I ran into

1. JS syntax: an extra bracket in the ternary

After adding the document branch next to the existing image one, the file wouldn't pass JS validation. The culprit was a nested ternary with one closing bracket too many:

// before (error)
type === 'image' ? renderImg() :
type === 'document' ? renderDoc()) : // ← extra )
renderText()
Enter fullscreen mode Exit fullscreen mode
// after (fixed)
type === 'image' ? renderImg() :
type === 'document' ? renderDoc() :
renderText()
Enter fullscreen mode Exit fullscreen mode

2. Automated find-and-replace fails on near-identical strings

When using a find-and-replace tool on a large file, if two code blocks are nearly identical — like two consecutive, almost-duplicate conditional branches — the tool can't tell them apart and either fails or hits the wrong one. Line-index-based substitution is the reliable fallback for large files with repeated patterns.

3. The Cloudinary preset must support resource_type raw

The Vocali preset had originally been created for audio only. On Cloudinary, an unsigned preset needs resource_type set to auto (or you create a dedicated raw preset) — otherwise uploading a PDF returns a 400 error. Fixed by switching the existing preset from audio to auto in the Cloudinary dashboard.

The final result

The chat now supports four message types: text, image, voice, and document. The Firebase data structure stays uniform — every message has type, sender, and ts as base fields, with type-specific fields layered on top.

  • Upload: Cloudinary CDN with resource_type=raw
  • Supported types: PDF, DOC/X, XLS/X, PPT/X, TXT, CSV, ZIP, RAR, 7z
  • Visual feedback: emoji icon by type, truncated filename, size, download arrow
  • List preview: 📎 FileName in the last-message row
  • Push notification: included, with text "Sent a document"

The entire pattern — CDN upload → Firebase → bubble render — is identical across media types. Adding a fifth type would only require a new render branch and the right resource_type on Cloudinary.

Takeaway

  1. resource_type=raw is mandatory for non-media files. It silently fails under image or video.
  2. Save fileName and fileSize to Firebase at upload time — you can't cheaply recover them from the Cloudinary URL later.
  3. Re-test JS validation after touching large files with nested ternaries. An extra bracket won't throw a build error; it just breaks runtime behavior silently.

Full write-up on my blog: roversia.it/blog-01-invio-documenti-chat.html

Top comments (0)