Matthias Nott
yesterday e6eb093cc63ba020799844905439f1cabfddcd3c
lib/widgets/message_bubble.dart
....@@ -468,8 +468,7 @@
468468 // Other files: save to temp and share
469469 final ext = _mimeToExt(mime);
470470 final dir = await getTemporaryDirectory();
471
- final fileName =
472
- '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
471
+ final fileName = _fileNameFor(message.content, ext);
473472 final file = File('${dir.path}/$fileName');
474473 await file.writeAsBytes(bytes);
475474
....@@ -483,6 +482,23 @@
483482 ).showSnackBar(SnackBar(content: Text('Could not open file: $e')));
484483 }
485484 }
485
+ }
486
+
487
+ /// The name to save an incoming file under.
488
+ ///
489
+ /// The caption is normally the sender's own filename, so it already carries
490
+ /// an extension. Appending another unconditionally produced `clip.mp4.mp4` —
491
+ /// and where the caption had been reduced to a trailing dot, `clip..bin`,
492
+ /// which no player would open until it was renamed by hand.
493
+ ///
494
+ /// So: sanitise, drop trailing dots, and add the extension only when the name
495
+ /// does not already end in it.
496
+ String _fileNameFor(String caption, String ext) {
497
+ var base = caption.replaceAll(RegExp(r'[^\w\s.-]'), '').trim();
498
+ base = base.replaceAll(RegExp(r'\.+$'), '').trim();
499
+ if (base.isEmpty) base = 'file';
500
+ if (base.toLowerCase().endsWith('.${ext.toLowerCase()}')) return base;
501
+ return '$base.$ext';
486502 }
487503
488504 String _mimeToExt(String mime) {
....@@ -503,8 +519,33 @@
503519 'application/xml': 'xml',
504520 'application/zip': 'zip',
505521 'application/gzip': 'gz',
522
+ // Video and audio were absent, which is how an mp4 became a `.bin` even
523
+ // once the sender labelled it correctly. The hub's table is the source
524
+ // this mirrors.
525
+ 'video/mp4': 'mp4',
526
+ 'video/quicktime': 'mov',
527
+ 'video/x-msvideo': 'avi',
528
+ 'video/x-matroska': 'mkv',
529
+ 'audio/mpeg': 'mp3',
530
+ 'audio/wav': 'wav',
531
+ 'audio/ogg': 'ogg',
532
+ 'audio/mp4': 'm4a',
533
+ 'image/jpeg': 'jpg',
534
+ 'image/png': 'png',
535
+ 'image/gif': 'gif',
536
+ 'image/webp': 'webp',
537
+ 'image/svg+xml': 'svg',
506538 };
507
- return map[mime] ?? 'bin';
539
+ final normalised = mime.toLowerCase().split(';').first.trim();
540
+ final known = map[normalised];
541
+ if (known != null) return known;
542
+ // A type this table does not carry but whose subtype names itself:
543
+ // image/heic -> heic. Better than calling it `bin`.
544
+ final subtype = normalised.split('/').length > 1
545
+ ? normalised.split('/')[1]
546
+ : '';
547
+ if (RegExp(r'^[a-z0-9]{2,5}$').hasMatch(subtype)) return subtype;
548
+ return 'bin';
508549 }
509550
510551 Widget _buildFooter(BuildContext context) {