Skip to content

Commit

Permalink
TF-3267 Implement HTML attachment preview
Browse files Browse the repository at this point in the history
  • Loading branch information
tddang-linagora committed Dec 6, 2024
1 parent 47b842d commit 16d817c
Show file tree
Hide file tree
Showing 12 changed files with 271 additions and 2 deletions.
7 changes: 7 additions & 0 deletions core/lib/domain/exceptions/string_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class UnsupportedCharsetException implements Exception {
const UnsupportedCharsetException();
}

class NullCharsetException implements Exception {
const NullCharsetException();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:convert';
import 'dart:math';

import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/utils/responsive_utils.dart';
import 'package:core/presentation/utils/shims/dart_ui.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:core/presentation/views/responsive/responsive_widget.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:universal_html/html.dart';

class HtmlAttachmentPreviewer extends StatefulWidget {
const HtmlAttachmentPreviewer({super.key, required this.htmlContent});

final String htmlContent;

@override
State<HtmlAttachmentPreviewer> createState() => _HtmlAttachmentPreviewerState();
}

class _HtmlAttachmentPreviewerState extends State<HtmlAttachmentPreviewer> {
late final IFrameElement _iframeElement;
late final String _viewId;

@override
void initState() {
super.initState();
_iframeElement = IFrameElement()
..srcdoc = widget.htmlContent
..style.border = 'none'
..style.overflow = 'hidden'
..style.width = '100%'
..style.height = '100%';
_viewId = _getRandString(10);

platformViewRegistry.registerViewFactory(_viewId, (int viewId) => _iframeElement);
}


@override
Widget build(BuildContext context) {
return Stack(
children: [
Center(
child: ResponsiveWidget(
responsiveUtils: ResponsiveUtils(),
desktop: Container(
width: MediaQuery.sizeOf(context).width * 0.4,
color: Colors.white,
margin: const EdgeInsets.symmetric(vertical: 16),
child: HtmlElementView(viewType: _viewId),
),
tablet: Container(
width: MediaQuery.sizeOf(context).width * 0.8,
color: Colors.white,
margin: const EdgeInsets.symmetric(vertical: 16),
child: HtmlElementView(viewType: _viewId),
),
mobile: Container(
width: MediaQuery.sizeOf(context).width,
color: Colors.white,
margin: const EdgeInsets.symmetric(vertical: 16),
child: HtmlElementView(viewType: _viewId),
),
),
),
Positioned(
top: 16,
right: 16,
child: PointerInterceptor(
child: TMailButtonWidget.fromIcon(
icon: ImagePaths().icClose,
iconSize: 40,
onTapActionCallback: () {
if (!mounted) return;
Get.back();
},
),
),
),
],
);
}

String _getRandString(int len) {
var random = Random.secure();
var values = List<int>.generate(len, (i) => random.nextInt(255));
return base64UrlEncode(values);
}
}
23 changes: 21 additions & 2 deletions core/lib/utils/string_convert.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:core/domain/exceptions/string_exception.dart';

class StringConvert {
static String? writeEmptyToNull(String text) {
static String? writeEmptyToNull(String text) {
if (text.isEmpty) return null;
return text;
}

static String writeNullToEmpty(String? text) {
static String writeNullToEmpty(String? text) {
return text ?? '';
}

static String decodeFromBytes(Uint8List bytes, {required String? charset}) {
if (charset == null) {
throw const NullCharsetException();
} else if (charset.toLowerCase().contains('utf-8')) {
return utf8.decode(bytes);
} else if (charset.toLowerCase().contains('latin')) {
return latin1.decode(bytes);
} else if (charset.toLowerCase().contains('ascii')) {
return ascii.decode(bytes);
} else {
throw const UnsupportedCharsetException();
}
}
}
10 changes: 10 additions & 0 deletions lib/features/email/data/repository/email_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,14 @@ class EmailRepositoryImpl extends EmailRepository {
emailId,
eventActionType);
}

@override
Future<String> sanitizeHtmlContent(
String htmlContent,
TransformConfiguration configuration
) {
return _htmlDataSource.transformHtmlEmailContent(
htmlContent,
configuration);
}
}
4 changes: 4 additions & 0 deletions lib/features/email/domain/repository/email_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,8 @@ abstract class EmailRepository {
AccountId accountId,
EmailId emailId,
EventActionType eventActionType);

Future<String> sanitizeHtmlContent(
String htmlContent,
TransformConfiguration configuration);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';

class GettingHtmlContentFromAttachment extends LoadingState {}

class GetHtmlContentFromAttachmentSuccess extends UIState {
GetHtmlContentFromAttachmentSuccess({required this.sanitizedHtmlContent});

final String sanitizedHtmlContent;

@override
List<Object?> get props => [sanitizedHtmlContent];
}

class GetHtmlContentFromAttachmentFailure extends FeatureFailure {
GetHtmlContentFromAttachmentFailure({super.exception});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'dart:async';

import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/presentation/utils/html_transformer/transform_configuration.dart';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/string_convert.dart';
import 'package:dartz/dartz.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:model/download/download_task_id.dart';
import 'package:model/email/attachment.dart';
import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart';

class GetHtmlContentFromAttachmentInteractor {
GetHtmlContentFromAttachmentInteractor(this._downloadAttachmentForWebInteractor);

final DownloadAttachmentForWebInteractor _downloadAttachmentForWebInteractor;

Stream<Either<Failure, Success>> execute(
AccountId accountId,
Attachment attachment,
DownloadTaskId taskId,
String baseDownloadUrl,
TransformConfiguration transformConfiguration,
) async* {
final onReceiveController = StreamController<Either<Failure, Success>>();
try {
yield Right(GettingHtmlContentFromAttachment());
final downloadState = await _downloadAttachmentForWebInteractor.execute(
taskId,
attachment,
accountId,
baseDownloadUrl,
onReceiveController,
).last;

Either<Failure, Success>? sanitizeState;
await downloadState.fold(
(failure) {
sanitizeState = Left(GetHtmlContentFromAttachmentFailure(
exception: failure is FeatureFailure ? failure.exception : null,
));
},
(success) async {
if (success is! DownloadAttachmentForWebSuccess) {
sanitizeState = Right(GettingHtmlContentFromAttachment());
} else {
final htmlContent = StringConvert.decodeFromBytes(
success.bytes,
charset: success.attachment.charset,
);
try {
final sanitizedHtmlContent = await _downloadAttachmentForWebInteractor
.emailRepository
.sanitizeHtmlContent(
htmlContent,
transformConfiguration,
);
sanitizeState = Right(GetHtmlContentFromAttachmentSuccess(
sanitizedHtmlContent: sanitizedHtmlContent,
));
} catch (e) {
sanitizeState = Left(GetHtmlContentFromAttachmentFailure(exception: e));
}
}
},
);

onReceiveController.close();
if (sanitizeState != null) {
yield sanitizeState!;
}

} catch (e) {
logError('GetHtmlContentFromAttachmentInteractor:exception: $e');
onReceiveController.close();
yield Left(GetHtmlContentFromAttachmentFailure(exception: e));
}
}
}
3 changes: 3 additions & 0 deletions lib/features/email/presentation/bindings/email_bindings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_email_read_
import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart';
import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart';
Expand Down Expand Up @@ -70,6 +71,7 @@ class EmailBindings extends BaseBindings {
Get.find<StoreOpenedEmailInteractor>(),
Get.find<PrintEmailInteractor>(),
Get.find<StoreEventAttendanceStatusInteractor>(),
Get.find<GetHtmlContentFromAttachmentInteractor>(),
));
}

Expand Down Expand Up @@ -152,6 +154,7 @@ class EmailBindings extends BaseBindings {
Get.lazyPut(() => PrintEmailInteractor(Get.find<EmailRepository>()));
Get.lazyPut(() => StoreEventAttendanceStatusInteractor(Get.find<EmailRepository>()));
IdentityInteractorsBindings().dependencies();
Get.lazyPut(() => GetHtmlContentFromAttachmentInteractor(Get.find<DownloadAttachmentForWebInteractor>()));
}

@override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:typed_data';

import 'package:better_open_file/better_open_file.dart' as open_file;
import 'package:core/core.dart';
import 'package:core/presentation/views/html_viewer/html_attachment_previewer.dart';
import 'package:dartz/dartz.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
Expand Down Expand Up @@ -50,6 +51,7 @@ import 'package:tmail_ui_user/features/email/domain/state/download_attachment_fo
import 'package:tmail_ui_user/features/email/domain/state/download_attachments_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/export_attachment_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_read_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart';
import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state.dart';
Expand All @@ -70,6 +72,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_
import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sender_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart';
Expand Down Expand Up @@ -134,6 +137,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
final StoreOpenedEmailInteractor _storeOpenedEmailInteractor;
final PrintEmailInteractor _printEmailInteractor;
final StoreEventAttendanceStatusInteractor _storeEventAttendanceStatusInteractor;
final GetHtmlContentFromAttachmentInteractor _getHtmlContentFromAttachmentInteractor;

CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor;
SendReceiptToSenderInteractor? _sendReceiptToSenderInteractor;
Expand Down Expand Up @@ -181,6 +185,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
this._storeOpenedEmailInteractor,
this._printEmailInteractor,
this._storeEventAttendanceStatusInteractor,
this._getHtmlContentFromAttachmentInteractor,
);

@override
Expand Down Expand Up @@ -236,6 +241,10 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
calendarEventSuccess(success);
} else if (success is StoreEventAttendanceStatusSuccess) {
_showToastMessageEventAttendanceSuccess(success);
} else if (success is GetHtmlContentFromAttachmentSuccess) {
Get.dialog(HtmlAttachmentPreviewer(
htmlContent: success.sanitizedHtmlContent,
));
}
}

Expand Down Expand Up @@ -1816,6 +1825,23 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
if (PlatformInfo.isWeb) {
if (PlatformInfo.isCanvasKit && attachment.validatePDFIcon()) {
previewPDFFileAction(context, attachment);
} else if (attachment.validateHtmlAttachment()) {
final accountId = mailboxDashBoardController.accountId.value;
final downloadUrl = mailboxDashBoardController.sessionCurrent
?.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl);
final blobId = attachment.blobId;

if (accountId == null || downloadUrl == null || blobId == null) return;

consumeState(_getHtmlContentFromAttachmentInteractor.execute(
accountId,
attachment,
DownloadTaskId(blobId.value),
downloadUrl,
TransformConfiguration.fromTextTransformers(
TransformConfiguration.standardTextTransformers,
),
));
} else {
downloadAttachmentForWeb(attachment);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ extension AttachmentExtension on Attachment {
String getIcon(ImagePaths imagePaths) => type?.getIcon(imagePaths, fileName: name) ?? imagePaths.icFileEPup;

bool validatePDFIcon() => type?.validatePDFIcon(fileName: name) ?? false;

bool validateHtmlAttachment() => type?.validateHtmlMediaType(fileName: name) ?? false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ extension MediaTypeExtension on MediaType {

bool validatePDFIcon({required String? fileName}) => mimeType == 'application/pdf' ||
(mimeType == 'application/octet-stream' && fileName?.endsWith('.pdf') == true);

bool validateHtmlMediaType({required String? fileName}) => mimeType == 'text/html' ||
(mimeType == 'application/octet-stream' && fileName?.endsWith('.html') == true);
}
Loading

0 comments on commit 16d817c

Please sign in to comment.