feat: Blurhashes and better thumbnails

This commit is contained in:
Sorunome 2020-09-03 12:58:54 +02:00
parent 169a2e8f90
commit 23218294f0
No known key found for this signature in database
GPG key ID: B19471D07FC9BE9C
8 changed files with 241 additions and 32 deletions

View file

@ -6,6 +6,9 @@
- Tapping links, pills, etc. now does stuff - Tapping links, pills, etc. now does stuff
### Fixes: ### Fixes:
- Various html rendering and url-ifying fixes - Various html rendering and url-ifying fixes
- Added support for blurhashes
- Use server-side generated thumbnails in cleartext rooms
- Image viewer now eventually displays the original image, not only the thumbnail
# Version 0.17.0 - 2020-08-31 # Version 0.17.0 - 2020-08-31
### Features ### Features

View file

@ -3,6 +3,10 @@ import 'package:famedlysdk/famedlysdk.dart';
import 'package:fluffychat/utils/app_route.dart'; import 'package:fluffychat/utils/app_route.dart';
import 'package:fluffychat/views/image_view.dart'; import 'package:fluffychat/views/image_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_advanced_networkimage/provider.dart';
import 'package:flutter_advanced_networkimage/transition.dart';
class ImageBubble extends StatefulWidget { class ImageBubble extends StatefulWidget {
final Event event; final Event event;
@ -11,6 +15,7 @@ class ImageBubble extends StatefulWidget {
final bool maxSize; final bool maxSize;
final Color backgroundColor; final Color backgroundColor;
final double radius; final double radius;
final bool thumbnailOnly;
const ImageBubble( const ImageBubble(
this.event, { this.event, {
@ -19,6 +24,7 @@ class ImageBubble extends StatefulWidget {
this.backgroundColor, this.backgroundColor,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.radius = 10.0, this.radius = 10.0,
this.thumbnailOnly = true,
Key key, Key key,
}) : super(key: key); }) : super(key: key);
@ -27,16 +33,39 @@ class ImageBubble extends StatefulWidget {
} }
class _ImageBubbleState extends State<ImageBubble> { class _ImageBubbleState extends State<ImageBubble> {
bool get isUnencrypted => widget.event.content['url'] is String;
static final Map<String, MatrixFile> _matrixFileMap = {}; static final Map<String, MatrixFile> _matrixFileMap = {};
MatrixFile get _file => _matrixFileMap[widget.event.eventId]; MatrixFile get _file => _matrixFileMap[widget.event.eventId];
set _file(MatrixFile file) { set _file(MatrixFile file) {
_matrixFileMap[widget.event.eventId] = file; if (file != null) {
_matrixFileMap[widget.event.eventId] = file;
}
}
static final Map<String, MatrixFile> _matrixThumbnailMap = {};
MatrixFile get _thumbnail => _matrixThumbnailMap[widget.event.eventId];
set _thumbnail(MatrixFile thumbnail) {
if (thumbnail != null) {
_matrixThumbnailMap[widget.event.eventId] = thumbnail;
}
} }
dynamic _error; dynamic _error;
bool _requestedFile = false;
Future<MatrixFile> _getFile() async { Future<MatrixFile> _getFile() async {
_requestedFile = true;
if (widget.thumbnailOnly) return null;
if (_file != null) return _file; if (_file != null) return _file;
return widget.event.downloadAndDecryptAttachment();
}
bool _requestedThumbnail = false;
Future<MatrixFile> _getThumbnail() async {
_requestedThumbnail = true;
if (isUnencrypted) return null;
if (_thumbnail != null) return _thumbnail;
return widget.event return widget.event
.downloadAndDecryptAttachment(getThumbnail: widget.event.hasThumbnail); .downloadAndDecryptAttachment(getThumbnail: widget.event.hasThumbnail);
} }
@ -60,32 +89,71 @@ class _ImageBubbleState extends State<ImageBubble> {
), ),
); );
} }
if (_file != null) { if (_thumbnail == null && !_requestedThumbnail && !isUnencrypted) {
return InkWell( _getThumbnail().then((MatrixFile thumbnail) {
onTap: () { setState(() => _thumbnail = thumbnail);
if (!widget.tapToView) return; }, onError: (error, stacktrace) {
Navigator.of(context).push( setState(() => _error = error);
AppRoute( });
ImageView(widget.event),
),
);
},
child: Hero(
tag: widget.event.eventId,
child: Image.memory(
_file.bytes,
fit: widget.fit,
),
),
);
} }
_getFile().then((MatrixFile file) { if (_file == null && !widget.thumbnailOnly && !_requestedFile) {
setState(() => _file = file); _getFile().then((MatrixFile file) {
}, onError: (error, stacktrace) { setState(() => _file = file);
setState(() => _error = error); }, onError: (error, stacktrace) {
}); setState(() => _error = error);
return Center( });
child: CircularProgressIndicator(), }
final display = _file ?? _thumbnail;
final generatePlaceholderWidget = () => Stack(
children: <Widget>[
if (widget.event.content['info'] is Map &&
widget.event.content['info']['xyz.amorgan.blurhash']
is String)
BlurHash(
hash: widget.event.content['info']
['xyz.amorgan.blurhash']),
Center(
child: CircularProgressIndicator(),
),
],
);
Widget renderWidget;
if (display != null) {
renderWidget = Image.memory(
display.bytes,
fit: widget.fit,
);
} else if (isUnencrypted) {
renderWidget = TransitionToImage(
image: AdvancedNetworkImage(
Uri.parse(widget.event.content['url']).getThumbnail(
widget.event.room.client,
width: 800,
height: 800,
method: ThumbnailMethod.scale),
useDiskCache: !kIsWeb,
),
loadingWidget: generatePlaceholderWidget(),
fit: widget.fit,
);
} else {
renderWidget = generatePlaceholderWidget();
}
return InkWell(
onTap: () {
if (!widget.tapToView) return;
Navigator.of(context).push(
AppRoute(
ImageView(widget.event),
),
);
},
child: Hero(
tag: widget.event.eventId,
child: renderWidget,
),
); );
}, },
), ),

View file

@ -3,9 +3,20 @@ import 'package:fluffychat/components/dialogs/simple_dialogs.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'matrix_file_extension.dart'; import 'matrix_file_extension.dart';
import 'app_route.dart';
import '../views/image_view.dart';
extension LocalizedBody on Event { extension LocalizedBody on Event {
void openFile(BuildContext context) async { void openFile(BuildContext context, {bool downloadOnly = false}) async {
if (!downloadOnly &&
[MessageTypes.Image, MessageTypes.Sticker].contains(messageType)) {
await Navigator.of(context).push(
AppRoute(
ImageView(this),
),
);
return;
}
final MatrixFile matrixFile = final MatrixFile matrixFile =
await SimpleDialogs(context).tryRequestWithLoadingDialog( await SimpleDialogs(context).tryRequestWithLoadingDialog(
downloadAndDecryptAttachment(), downloadAndDecryptAttachment(),
@ -32,7 +43,12 @@ extension LocalizedBody on Event {
[MessageTypes.Image, MessageTypes.Sticker].contains(messageType) && [MessageTypes.Image, MessageTypes.Sticker].contains(messageType) &&
(kIsWeb || (kIsWeb ||
(content['info'] is Map && (content['info'] is Map &&
content['info']['size'] < room.client.database.maxFileSize)); content['info']['size'] < room.client.database.maxFileSize) ||
(hasThumbnail &&
content['info']['thumbnail_info'] is Map &&
content['info']['thumbnail_info']['size'] <
room.client.database.maxFileSize) ||
(content['url'] is String));
String get sizeString { String get sizeString {
if (content['info'] is Map<String, dynamic> && if (content['info'] is Map<String, dynamic> &&

View file

@ -0,0 +1,98 @@
/*
* Famedly App
* Copyright (C) 2020 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:typed_data';
import 'dart:ui';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:native_imaging/native_imaging.dart' as native;
extension RoomSendFileExtension on Room {
Future<String> sendFileEventWithThumbnail(
MatrixFile file, {
String txid,
Event inReplyTo,
String editEventId,
bool waitUntilSent,
}) async {
MatrixFile thumbnail;
try {
if (file is MatrixImageFile) {
await native.init();
var nativeImg = native.Image();
try {
await nativeImg.loadEncoded(file.bytes);
file.width = nativeImg.width();
file.height = nativeImg.height();
} on UnsupportedError {
final dartCodec = await instantiateImageCodec(file.bytes);
final dartFrame = await dartCodec.getNextFrame();
file.width = dartFrame.image.width;
file.height = dartFrame.image.height;
final rgbaData = await dartFrame.image.toByteData();
final rgba = Uint8List.view(
rgbaData.buffer, rgbaData.offsetInBytes, rgbaData.lengthInBytes);
dartFrame.image.dispose();
dartCodec.dispose();
nativeImg.loadRGBA(file.width, file.height, rgba);
}
const max = 800;
if (file.width > max || file.height > max) {
var w = max, h = max;
if (file.width > file.height) {
h = max * file.height ~/ file.width;
} else {
w = max * file.width ~/ file.height;
}
final scaledImg = nativeImg.resample(w, h, native.Transform.lanczos);
nativeImg.free();
nativeImg = scaledImg;
}
final jpegBytes = await nativeImg.toJpeg(75);
file.blurhash = nativeImg.toBlurhash(3, 3);
thumbnail = MatrixImageFile(
bytes: jpegBytes,
name: 'thumbnail.jpg',
mimeType: 'image/jpeg',
width: nativeImg.width(),
height: nativeImg.height(),
);
nativeImg.free();
if (thumbnail.size > file.size ~/ 2) {
thumbnail = null;
}
}
} catch (e) {
// send no thumbnail
}
return sendFileEvent(
file,
txid: txid,
inReplyTo: inReplyTo,
editEventId: editEventId,
waitUntilSent: waitUntilSent ?? false,
thumbnail: thumbnail,
);
}
}

View file

@ -27,6 +27,7 @@ import 'package:image_picker/image_picker.dart';
import 'chat_details.dart'; import 'chat_details.dart';
import 'chat_list.dart'; import 'chat_list.dart';
import '../components/input_bar.dart'; import '../components/input_bar.dart';
import '../utils/room_send_file_extension.dart';
class ChatView extends StatelessWidget { class ChatView extends StatelessWidget {
final String id; final String id;
@ -191,7 +192,7 @@ class _ChatState extends State<_Chat> {
var file = await MemoryFilePicker.getFile(); var file = await MemoryFilePicker.getFile();
if (file == null) return; if (file == null) return;
await SimpleDialogs(context).tryRequestWithLoadingDialog( await SimpleDialogs(context).tryRequestWithLoadingDialog(
room.sendFileEvent( room.sendFileEventWithThumbnail(
MatrixFile(bytes: file.bytes, name: file.path), MatrixFile(bytes: file.bytes, name: file.path),
), ),
); );
@ -205,7 +206,7 @@ class _ChatState extends State<_Chat> {
maxHeight: 1600); maxHeight: 1600);
if (file == null) return; if (file == null) return;
await SimpleDialogs(context).tryRequestWithLoadingDialog( await SimpleDialogs(context).tryRequestWithLoadingDialog(
room.sendFileEvent( room.sendFileEventWithThumbnail(
MatrixImageFile(bytes: await file.bytes, name: file.path), MatrixImageFile(bytes: await file.bytes, name: file.path),
), ),
); );
@ -219,7 +220,7 @@ class _ChatState extends State<_Chat> {
maxHeight: 1600); maxHeight: 1600);
if (file == null) return; if (file == null) return;
await SimpleDialogs(context).tryRequestWithLoadingDialog( await SimpleDialogs(context).tryRequestWithLoadingDialog(
room.sendFileEvent( room.sendFileEventWithThumbnail(
MatrixImageFile(bytes: file.bytes, name: file.path), MatrixImageFile(bytes: file.bytes, name: file.path),
), ),
); );

View file

@ -36,7 +36,7 @@ class ImageView extends StatelessWidget {
), ),
IconButton( IconButton(
icon: Icon(Icons.file_download), icon: Icon(Icons.file_download),
onPressed: () => event.openFile(context), onPressed: () => event.openFile(context, downloadOnly: true),
color: Colors.white, color: Colors.white,
), ),
], ],
@ -51,6 +51,7 @@ class ImageView extends StatelessWidget {
backgroundColor: Colors.black, backgroundColor: Colors.black,
maxSize: false, maxSize: false,
radius: 0.0, radius: 0.0,
thumbnailOnly: false,
), ),
), ),
); );

View file

@ -241,6 +241,13 @@ packages:
url: "https://github.com/mchome/flutter_advanced_networkimage" url: "https://github.com/mchome/flutter_advanced_networkimage"
source: git source: git
version: "0.8.0" version: "0.8.0"
flutter_blurhash:
dependency: "direct main"
description:
name: flutter_blurhash
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
flutter_keyboard_visibility: flutter_keyboard_visibility:
dependency: transitive dependency: transitive
description: description:
@ -508,6 +515,15 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.1" version: "3.3.1"
native_imaging:
dependency: "direct main"
description:
path: "."
ref: master
resolved-ref: bd24832f96537447174aa34ba78eaed7ff05bb8e
url: "https://gitlab.com/famedly/libraries/native_imaging.git"
source: git
version: "0.0.1"
node_interop: node_interop:
dependency: transitive dependency: transitive
description: description:

View file

@ -65,6 +65,12 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
sqflite: ^1.1.7 # Still used to obtain the database location sqflite: ^1.1.7 # Still used to obtain the database location
native_imaging:
git:
url: https://gitlab.com/famedly/libraries/native_imaging.git
ref: master
flutter_blurhash: ^0.5.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: