Merge branch 'soru/better-image-fallback' into 'main'

cleanup: Better image bubble error handling

See merge request famedly/fluffychat!442
This commit is contained in:
Sorunome 2021-07-18 17:00:24 +00:00
commit 4ab737a174
2 changed files with 130 additions and 32 deletions

View File

@ -2086,6 +2086,11 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"tapToShowImage": "Tap to show image",
"@tapToShowImage": {
"type": "text",
"placeholders": {}
},
"theyDontMatch": "They Don't Match", "theyDontMatch": "They Don't Match",
"@theyDontMatch": { "@theyDontMatch": {
"type": "text", "type": "text",

View File

@ -8,6 +8,7 @@ import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/matrix_sdk_extensions.dart/event_extension.dart'; import '../../utils/matrix_sdk_extensions.dart/event_extension.dart';
import '../matrix.dart'; import '../matrix.dart';
@ -39,12 +40,26 @@ class ImageBubble extends StatefulWidget {
} }
class _ImageBubbleState extends State<ImageBubble> { class _ImageBubbleState extends State<ImageBubble> {
// for plaintext: holds the http URL for the thumbnail
String thumbnailUrl; String thumbnailUrl;
// for plaintext: holds the http URL of the original
String attachmentUrl; String attachmentUrl;
MatrixFile _file; MatrixFile _file;
MatrixFile _thumbnail; MatrixFile _thumbnail;
bool _requestedThumbnailOnFailure = false; bool _requestedThumbnailOnFailure = false;
// the mimetypes that we know how to render, from the flutter Image widget
final _knownMimetypes = <String>{
'image/jpg',
'image/jpeg',
'image/png',
'image/apng',
'image/webp',
'image/gif',
'image/bmp',
'image/x-bmp',
};
// overrides for certain mimetypes if they need different images to render // overrides for certain mimetypes if they need different images to render
// memory are for in-memory renderers (e2ee rooms), network for network url renderers. // memory are for in-memory renderers (e2ee rooms), network for network url renderers.
// The map values themself are set in initState() as they need to be able to access // The map values themself are set in initState() as they need to be able to access
@ -52,8 +67,8 @@ class _ImageBubbleState extends State<ImageBubble> {
final _contentRenderers = <String, _ImageBubbleContentRenderer>{}; final _contentRenderers = <String, _ImageBubbleContentRenderer>{};
String getMimetype([bool thumbnail = false]) => thumbnail String getMimetype([bool thumbnail = false]) => thumbnail
? widget.event.thumbnailMimetype ? widget.event.thumbnailMimetype.toLowerCase()
: widget.event.attachmentMimetype; : widget.event.attachmentMimetype.toLowerCase();
MatrixFile get _displayFile => _file ?? _thumbnail; MatrixFile get _displayFile => _file ?? _thumbnail;
String get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl; String get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl;
@ -89,6 +104,7 @@ class _ImageBubbleState extends State<ImageBubble> {
@override @override
void initState() { void initState() {
// add the custom renderers for other mimetypes
_contentRenderers['image/svg+xml'] = _ImageBubbleContentRenderer( _contentRenderers['image/svg+xml'] = _ImageBubbleContentRenderer(
memory: (Uint8List bytes, String key) => SvgPicture.memory( memory: (Uint8List bytes, String key) => SvgPicture.memory(
bytes, bytes,
@ -107,14 +123,23 @@ class _ImageBubbleState extends State<ImageBubble> {
bytes, bytes,
key: ValueKey(key), key: ValueKey(key),
fit: widget.fit, fit: widget.fit,
errorBuilder: (context, error, stacktrace) =>
getErrorWidget(context, error),
), ),
network: (String url) => Lottie.network( network: (String url) => Lottie.network(
url, url,
key: ValueKey(url), key: ValueKey(url),
fit: widget.fit, fit: widget.fit,
errorBuilder: (context, error, stacktrace) =>
getErrorWidget(context, error),
), ),
); );
// add all the custom content renderer mimetypes to the known mimetypes set
for (final key in _contentRenderers.keys) {
_knownMimetypes.add(key);
}
thumbnailUrl = widget.event thumbnailUrl = widget.event
.getAttachmentUrl(getThumbnail: true, animated: true) .getAttachmentUrl(getThumbnail: true, animated: true)
?.toString(); ?.toString();
@ -136,15 +161,43 @@ class _ImageBubbleState extends State<ImageBubble> {
super.initState(); super.initState();
} }
Widget getErrorWidget() { Widget getErrorWidget(BuildContext context, [dynamic error]) {
final String filename = widget.event.content.containsKey('filename')
? widget.event.content['filename']
: widget.event.body;
return Center( return Center(
child: Text( child: Column(
_error.toString(), mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => widget.event.saveFile(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_outlined),
SizedBox(width: 8),
Text(
filename,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
],
),
),
if (widget.event.sizeString != null) Text(widget.event.sizeString),
SizedBox(height: 8),
Text((error ?? _error).toString()),
],
), ),
); );
} }
Widget getPlaceholderWidget() { Widget getPlaceholderWidget({Widget child}) {
Widget blurhash; Widget blurhash;
if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) { if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) {
final ratio = final ratio =
@ -169,12 +222,13 @@ class _ImageBubbleState extends State<ImageBubble> {
children: <Widget>[ children: <Widget>[
if (blurhash != null) blurhash, if (blurhash != null) blurhash,
Center( Center(
child: CircularProgressIndicator(strokeWidth: 2), child: child ?? CircularProgressIndicator(strokeWidth: 2),
), ),
], ],
); );
} }
// Build a memory file (e2ee)
Widget getMemoryWidget() { Widget getMemoryWidget() {
final isOriginal = _file != null || final isOriginal = _file != null ||
widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) == widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) ==
@ -190,12 +244,20 @@ class _ImageBubbleState extends State<ImageBubble> {
_displayFile.bytes, _displayFile.bytes,
key: ValueKey(key), key: ValueKey(key),
fit: widget.fit, fit: widget.fit,
errorBuilder: (context, error, stacktrace) =>
getErrorWidget(context, error),
); );
} }
} }
// build a network file (plaintext)
Widget getNetworkWidget() { Widget getNetworkWidget() {
final mimetype = getMimetype(!_requestedThumbnailOnFailure); // For network files we try to utilize server-side thumbnailing as much as possible.
// Thus, we do the following logic:
// - try to display our URL
// - on failure: Attempt to display the in-event thumbnail instead
// - on failrue / non-existance: Display button to download or view in-app
final mimetype = getMimetype(_requestedThumbnailOnFailure);
if (displayUrl == attachmentUrl && if (displayUrl == attachmentUrl &&
_contentRenderers.containsKey(mimetype)) { _contentRenderers.containsKey(mimetype)) {
return _contentRenderers[mimetype].network(displayUrl); return _contentRenderers[mimetype].network(displayUrl);
@ -219,8 +281,9 @@ class _ImageBubbleState extends State<ImageBubble> {
return getPlaceholderWidget(); return getPlaceholderWidget();
}, },
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
// we can re-request the thumbnail if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) {
if (!_requestedThumbnailOnFailure) { // the image failed to load but the event has a thumbnail attached....so we can
// try to load this one!
_requestedThumbnailOnFailure = true; _requestedThumbnailOnFailure = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { setState(() {
@ -235,8 +298,36 @@ class _ImageBubbleState extends State<ImageBubble> {
?.toString(); ?.toString();
}); });
}); });
return getPlaceholderWidget();
} else if (widget.thumbnailOnly &&
displayUrl != attachmentUrl &&
_knownMimetypes.contains(mimetype)) {
// Okay, the thumbnail failed to load, but we do know how to render the image
// ourselves. Let's offer the user a button to view it.
return getPlaceholderWidget(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).scaffoldBackgroundColor,
onPrimary: Theme.of(context).textTheme.bodyText1.color,
),
onPressed: () => onTap(context),
child: Text(
L10n.of(context).tapToShowImage,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
),
if (widget.event.sizeString != null)
Text(widget.event.sizeString),
],
));
} }
return getPlaceholderWidget(); return getErrorWidget(context, error);
}, },
fit: widget.fit, fit: widget.fit,
); );
@ -248,7 +339,7 @@ class _ImageBubbleState extends State<ImageBubble> {
Widget content; Widget content;
String key; String key;
if (_error != null) { if (_error != null) {
content = getErrorWidget(); content = getErrorWidget(context);
key = 'error'; key = 'error';
} else if (_displayFile != null) { } else if (_displayFile != null) {
content = getMemoryWidget(); content = getMemoryWidget();
@ -263,26 +354,7 @@ class _ImageBubbleState extends State<ImageBubble> {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(widget.radius), borderRadius: BorderRadius.circular(widget.radius),
child: InkWell( child: InkWell(
onTap: () { onTap: () => onTap(context),
if (!widget.tapToView) return;
showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => ImageViewer(widget.event, onLoaded: () {
// If the original file didn't load yet, we want to do that now.
// This is so that the original file displays after going on the image viewer,
// waiting for it to load, and then hitting back. This ensures that we always
// display the best image available, with requiring as little network as possible
if (_file == null) {
widget.event.isAttachmentCached().then((cached) {
if (cached) {
_requestFile();
}
});
}
}),
);
},
child: Hero( child: Hero(
tag: widget.event.eventId, tag: widget.event.eventId,
child: AnimatedSwitcher( child: AnimatedSwitcher(
@ -298,6 +370,27 @@ class _ImageBubbleState extends State<ImageBubble> {
), ),
); );
} }
void onTap(BuildContext context) {
if (!widget.tapToView) return;
showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => ImageViewer(widget.event, onLoaded: () {
// If the original file didn't load yet, we want to do that now.
// This is so that the original file displays after going on the image viewer,
// waiting for it to load, and then hitting back. This ensures that we always
// display the best image available, with requiring as little network as possible
if (_file == null) {
widget.event.isAttachmentCached().then((cached) {
if (cached) {
_requestFile();
}
});
}
}),
);
}
} }
class _ImageBubbleContentRenderer { class _ImageBubbleContentRenderer {