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
- CSS — document bubble. Translucent blue background, file-type icon, truncated filename, download button.
-
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. -
Upload to Cloudinary with
resource_type=raw. Binary files can't go underimageorvideo— more on this below. -
Bubble render in
_buildMsgEl(). A dynamic icon per file type, filename, size in KB/MB, click to open in a new tab. -
Chat list preview update. DM and group previews show
📎 FileNameas 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;
}
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);
}
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] ?? '📎';
}
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()
// after (fixed)
type === 'image' ? renderImg() :
type === 'document' ? renderDoc() :
renderText()
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:
📎 FileNamein 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
-
resource_type=rawis mandatory for non-media files. It silently fails underimageorvideo. - Save fileName and fileSize to Firebase at upload time — you can't cheaply recover them from the Cloudinary URL later.
- 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)