From df89ce94d7538a303b7d5f8b86f473775a042e6c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Wed, 12 Jun 2024 19:38:42 +0200 Subject: [PATCH 1/2] feat(neon_framework): Add ReferencesBloc Signed-off-by: provokateurin --- packages/neon_framework/lib/blocs.dart | 1 + packages/neon_framework/lib/src/app.dart | 3 + .../lib/src/blocs/accounts.dart | 17 +- .../lib/src/blocs/references.dart | 128 ++++++++++++ .../neon_framework/lib/src/testing/mocks.dart | 2 + .../test/references_bloc_test.dart | 183 ++++++++++++++++++ 6 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 packages/neon_framework/lib/src/blocs/references.dart create mode 100644 packages/neon_framework/test/references_bloc_test.dart diff --git a/packages/neon_framework/lib/blocs.dart b/packages/neon_framework/lib/blocs.dart index af5c58bd733..f37187246da 100644 --- a/packages/neon_framework/lib/blocs.dart +++ b/packages/neon_framework/lib/blocs.dart @@ -1,6 +1,7 @@ export 'package:neon_framework/src/bloc/bloc.dart'; export 'package:neon_framework/src/bloc/result.dart'; export 'package:neon_framework/src/blocs/apps.dart'; +export 'package:neon_framework/src/blocs/references.dart'; export 'package:neon_framework/src/blocs/timer.dart'; export 'package:neon_framework/src/blocs/user_details.dart'; export 'package:neon_framework/src/blocs/user_status.dart'; diff --git a/packages/neon_framework/lib/src/app.dart b/packages/neon_framework/lib/src/app.dart index 0837e76141d..8b5b5f3fec5 100644 --- a/packages/neon_framework/lib/src/app.dart +++ b/packages/neon_framework/lib/src/app.dart @@ -272,6 +272,9 @@ class _NeonAppState extends State with WidgetsBindingObserver, WindowLi NeonProvider.value( value: _accountsBloc.getMaintenanceModeBlocFor(account), ), + NeonProvider.value( + value: _accountsBloc.getReferencesBlocFor(account), + ), ], child: app, ); diff --git a/packages/neon_framework/lib/src/blocs/accounts.dart b/packages/neon_framework/lib/src/blocs/accounts.dart index 5f2a7f19f13..e2c434fa648 100644 --- a/packages/neon_framework/lib/src/blocs/accounts.dart +++ b/packages/neon_framework/lib/src/blocs/accounts.dart @@ -6,14 +6,10 @@ import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:neon_framework/src/bloc/bloc.dart'; -import 'package:neon_framework/src/blocs/apps.dart'; +import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/src/blocs/capabilities.dart'; import 'package:neon_framework/src/blocs/maintenance_mode.dart'; import 'package:neon_framework/src/blocs/unified_search.dart'; -import 'package:neon_framework/src/blocs/user_details.dart'; -import 'package:neon_framework/src/blocs/user_status.dart'; -import 'package:neon_framework/src/blocs/weather_status.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/account_cache.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; @@ -93,6 +89,9 @@ abstract interface class AccountsBloc implements Disposable { /// The MaintenanceModeBloc for the specified [account]. MaintenanceModeBloc getMaintenanceModeBlocFor(Account account); + + /// The ReferencesBloc for the specified [account]. + ReferencesBloc getReferencesBlocFor(Account account); } /// Implementation of [AccountsBloc]. @@ -171,6 +170,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { final unifiedSearchBlocs = AccountCache(); final weatherStatusBlocs = AccountCache(); final maintenanceModeBlocs = AccountCache(); + final referencesBlocs = AccountCache(); @override void dispose() { @@ -183,6 +183,7 @@ class _AccountsBloc extends Bloc implements AccountsBloc { unifiedSearchBlocs.dispose(); weatherStatusBlocs.dispose(); maintenanceModeBlocs.dispose(); + referencesBlocs.dispose(); accountsOptions.dispose(); } @@ -300,6 +301,12 @@ class _AccountsBloc extends Bloc implements AccountsBloc { maintenanceModeBlocs[account] ??= MaintenanceModeBloc( account: account, ); + + @override + ReferencesBloc getReferencesBlocFor(Account account) => referencesBlocs[account] ??= ReferencesBloc( + account: account, + capabilities: getCapabilitiesBlocFor(account).capabilities, + ); } /// Gets a list of logged in accounts from storage. diff --git a/packages/neon_framework/lib/src/blocs/references.dart b/packages/neon_framework/lib/src/blocs/references.dart new file mode 100644 index 00000000000..e2a9c57331e --- /dev/null +++ b/packages/neon_framework/lib/src/blocs/references.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:rxdart/rxdart.dart'; + +/// The Bloc responsible for loading URL references. +@sealed +abstract interface class ReferencesBloc implements Bloc { + factory ReferencesBloc({ + required Account account, + required BehaviorSubject> capabilities, + }) = _ReferencesBloc; + + /// Loads new references from the given URLs. + void loadReferences(BuiltList references); + + /// The Regex used for extracting references. + BehaviorSubject> get referenceRegex; + + /// Map of resolved references and their details. + BehaviorSubject>> get references; +} + +class _ReferencesBloc extends Bloc implements ReferencesBloc { + _ReferencesBloc({ + required this.account, + required this.capabilities, + }) { + capabilities.listen((result) { + referenceRegex.add( + result.transform((data) { + final regex = data.capabilities.coreCapabilities?.core.referenceRegex; + if (regex != null) { + return RegExp( + regex, + multiLine: true, + caseSensitive: false, + ); + } + + return null; + }), + ); + }); + } + + final Account account; + final BehaviorSubject> capabilities; + + @override + final log = Logger('ReferencesBloc'); + + @override + final referenceRegex = BehaviorSubject.seeded(Result.success(null)); + + @override + final references = BehaviorSubject.seeded(BuiltMap()); + + @override + void dispose() { + unawaited(referenceRegex.close()); + unawaited(references.close()); + } + + @override + Future loadReferences(BuiltList references) async { + // Skip references that were already resolved successfully. + final filteredReferences = references.rebuild((b) { + b.removeWhere((reference) => this.references.value[reference]?.hasSuccessfulData ?? false); + }); + if (filteredReferences.isEmpty) { + return; + } + + this.references.add( + this.references.value.rebuild((b) { + for (final reference in filteredReferences) { + b[reference] = b[reference]?.asLoading() ?? Result.loading(); + } + }), + ); + + try { + await RequestManager.instance.timeout(() async { + final response = await account.client.core.referenceApi.resolve( + $body: core.ReferenceApiResolveRequestApplicationJson( + (b) => b + ..references.replace(filteredReferences) + // Just always load all references. + // If less are desired simply pass only the ones you want or filter the results. + // TBH I don't understand why this logic is handled by the API and not the clients. + ..limit = filteredReferences.length, + ), + ); + + this.references.add( + this.references.value.rebuild((b) { + for (final entry in response.body.ocs.data.references.entries) { + b[entry.key] = Result.success(entry.value); + } + }), + ); + }); + } on Exception catch (error, stackTrace) { + log.info( + 'Error resolving references', + error, + stackTrace, + ); + + final result = Result.error(error); + + this.references.add( + this.references.value.rebuild((b) { + for (final reference in filteredReferences) { + b[reference] = result; + } + }), + ); + } + } +} diff --git a/packages/neon_framework/lib/src/testing/mocks.dart b/packages/neon_framework/lib/src/testing/mocks.dart index 7c1f3fa437a..fbff01deb81 100644 --- a/packages/neon_framework/lib/src/testing/mocks.dart +++ b/packages/neon_framework/lib/src/testing/mocks.dart @@ -102,3 +102,5 @@ class MockGoRouter extends Mock implements GoRouter {} class MockGoRouterState extends Mock implements GoRouterState {} class MockUrlLauncher extends Mock with MockPlatformInterfaceMixin implements UrlLauncherPlatform {} + +class MockReferencesBloc extends Mock implements ReferencesBloc {} diff --git a/packages/neon_framework/test/references_bloc_test.dart b/packages/neon_framework/test/references_bloc_test.dart new file mode 100644 index 00000000000..d875c27849e --- /dev/null +++ b/packages/neon_framework/test/references_bloc_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:rxdart/rxdart.dart'; + +Account mockReferencesAccount() => mockServer({ + RegExp(r'/ocs/v2\.php/references/resolve'): { + 'post': (match, bodyBytes) { + final data = json.decode(utf8.decode(bodyBytes)) as Map; + final references = data['references']! as List; + if (references.contains('error')) { + return Response('', 400); + } + + return Response( + json.encode( + { + 'ocs': { + 'meta': {'status': '', 'statuscode': 0}, + 'data': { + 'references': { + for (final reference in references) + reference: { + 'richObjectType': '', + 'richObject': {}, + 'openGraphObject': { + 'id': '', + 'name': '', + 'link': reference, + }, + 'accessible': false, + }, + }, + }, + }, + }, + ), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + }, + }); + +core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data buildCapabilities() => + core.OcsGetCapabilitiesResponseApplicationJson_Ocs_Data( + (b) => b + ..version.update( + (b) => b + ..major = 0 + ..minor = 0 + ..micro = 0 + ..string = '' + ..edition = '' + ..extendedSupport = false, + ) + ..capabilities = ( + commentsCapabilities: null, + coreCapabilities: core.CoreCapabilities( + (b) => b + ..core.update( + (b) => b + ..referenceRegex = '[a-z]+' + ..referenceApi = true + ..pollinterval = 0 + ..webdavRoot = '', + ), + ), + corePublicCapabilities: null, + davCapabilities: null, + dropAccountCapabilities: null, + filesCapabilities: null, + filesSharingCapabilities: null, + filesTrashbinCapabilities: null, + filesVersionsCapabilities: null, + notesCapabilities: null, + notificationsCapabilities: null, + provisioningApiCapabilities: null, + sharebymailCapabilities: null, + spreedCapabilities: null, + spreedPublicCapabilities: null, + systemtagsCapabilities: null, + themingPublicCapabilities: null, + userStatusCapabilities: null, + weatherStatusCapabilities: null, + ), + ); + +void main() { + late Account account; + late BehaviorSubject> capabilities; + late ReferencesBloc bloc; + + setUpAll(() { + FakeNeonStorage.setup(); + }); + + setUp(() { + account = mockReferencesAccount(); + + capabilities = BehaviorSubject.seeded(Result.success(buildCapabilities())); + + bloc = ReferencesBloc( + account: account, + capabilities: capabilities, + ); + }); + + tearDown(() { + unawaited(capabilities.close()); + bloc.dispose(); + }); + + test('referenceRegex', () { + expect( + bloc.referenceRegex.map( + (result) => result.transform( + (data) => ( + data!.pattern, + data.isMultiLine, + data.isCaseSensitive, + ), + ), + ), + emitsInOrder([ + Result.success(('[a-z]+', true, false)), + ]), + ); + }); + + test('loadReferences', () async { + expect( + bloc.references.map((e) => e.map((k, v) => MapEntry(k, v.transform((d) => d.openGraphObject.link)))), + emitsInOrder(>>[ + BuiltMap(), + BuiltMap({ + 'a': Result.loading(), + 'b': Result.loading(), + 'c': Result.loading(), + }), + BuiltMap({ + 'a': Result.success('a'), + 'b': Result.success('b'), + 'c': Result.success('c'), + }), + BuiltMap({ + 'a': Result.success('a'), + 'b': Result.success('b'), + 'c': Result.success('c'), + 'd': Result.loading(), + }), + BuiltMap({ + 'a': Result.success('a'), + 'b': Result.success('b'), + 'c': Result.success('c'), + 'd': Result.success('d'), + }), + ]), + ); + + bloc.loadReferences( + BuiltList([ + 'a', + 'b', + 'c', + ]), + ); + await Future.delayed(const Duration(milliseconds: 1)); + bloc.loadReferences( + BuiltList([ + 'a', + 'd', + ]), + ); + }); +} From 17bb8e116f7b81e5f7095d37caf1d0b34226b886 Mon Sep 17 00:00:00 2001 From: provokateurin Date: Sun, 14 Jul 2024 19:33:08 +0200 Subject: [PATCH 2/2] feat(neon_talk): Display references inside chat messages Signed-off-by: provokateurin --- .../neon_talk/lib/src/blocs/message_bloc.dart | 88 ++++ .../neon_talk/lib/src/widgets/message.dart | 441 +++++++++++------- .../lib/src/widgets/reference_preview.dart | 104 +++++ ...essage_comment_message_with_references.png | Bin 0 -> 7051 bytes .../test/goldens/reference_preview.png | Bin 0 -> 4644 bytes .../reference_preview_with_description.png | Bin 0 -> 4759 bytes .../goldens/reference_preview_with_thumb.png | Bin 0 -> 4647 bytes .../neon_talk/test/message_bloc_test.dart | 75 +++ .../neon/neon_talk/test/message_test.dart | 142 +++++- .../test/reference_preview_test.dart | 121 +++++ .../neon/neon_talk/test/room_page_test.dart | 9 +- packages/neon/neon_talk/test/testing.dart | 5 + 12 files changed, 797 insertions(+), 188 deletions(-) create mode 100644 packages/neon/neon_talk/lib/src/blocs/message_bloc.dart create mode 100644 packages/neon/neon_talk/lib/src/widgets/reference_preview.dart create mode 100644 packages/neon/neon_talk/test/goldens/message_comment_message_with_references.png create mode 100644 packages/neon/neon_talk/test/goldens/reference_preview.png create mode 100644 packages/neon/neon_talk/test/goldens/reference_preview_with_description.png create mode 100644 packages/neon/neon_talk/test/goldens/reference_preview_with_thumb.png create mode 100644 packages/neon/neon_talk/test/message_bloc_test.dart create mode 100644 packages/neon/neon_talk/test/reference_preview_test.dart diff --git a/packages/neon/neon_talk/lib/src/blocs/message_bloc.dart b/packages/neon/neon_talk/lib/src/blocs/message_bloc.dart new file mode 100644 index 00000000000..3506393440e --- /dev/null +++ b/packages/neon/neon_talk/lib/src/blocs/message_bloc.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:built_collection/built_collection.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:nextcloud/spreed.dart' as spreed; +import 'package:rxdart/rxdart.dart'; + +/// Bloc for handling the message state and interactions. +@sealed +abstract class TalkMessageBloc implements Bloc { + /// Creates a new Talk message bloc. + factory TalkMessageBloc({ + required spreed.$ChatMessageInterface chatMessage, + required ReferencesBloc referencesBloc, + required bool isParent, + }) = _TalkMessageBloc; + + /// References contained in the message. + BehaviorSubject>> get references; +} + +class _TalkMessageBloc extends Bloc implements TalkMessageBloc { + _TalkMessageBloc({ + required this.chatMessage, + required this.referencesBloc, + required this.isParent, + }) { + if (!isParent) { + referenceRegexSubscription = referencesBloc.referenceRegex.listen((result) { + final referenceRegex = result.data; + if (referenceRegex == null) { + return; + } + + final matches = + referenceRegex.allMatches(chatMessage.message).map((match) => match.group(0)!.trim()).toBuiltList(); + + references.add( + references.value.rebuild((b) { + for (final match in matches) { + b[match] ??= Result.loading(); + } + + b.removeWhere((key, value) => !matches.contains(key)); + }), + ); + + if (matches.isNotEmpty) { + referencesBloc.loadReferences(matches); + } + }); + + referencesSubscription = referencesBloc.references.listen((result) { + references.add( + references.value.rebuild((b) { + for (final url in references.value.keys) { + b[url] = result[url] ?? Result.loading(); + } + + b.removeWhere((key, value) => !result.keys.contains(key)); + }), + ); + }); + } + } + + final spreed.$ChatMessageInterface chatMessage; + final ReferencesBloc referencesBloc; + final bool isParent; + StreamSubscription>? referenceRegexSubscription; + StreamSubscription>>? referencesSubscription; + + @override + Logger log = Logger('TalkMessageBloc'); + + @override + void dispose() { + unawaited(referenceRegexSubscription?.cancel()); + unawaited(referencesSubscription?.cancel()); + unawaited(references.close()); + } + + @override + final references = BehaviorSubject.seeded(BuiltMap()); +} diff --git a/packages/neon/neon_talk/lib/src/widgets/message.dart b/packages/neon/neon_talk/lib/src/widgets/message.dart index 685cdb8c6c7..989574d069a 100644 --- a/packages/neon/neon_talk/lib/src/widgets/message.dart +++ b/packages/neon/neon_talk/lib/src/widgets/message.dart @@ -1,15 +1,20 @@ +import 'package:built_collection/built_collection.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:intersperse/intersperse.dart'; import 'package:intl/intl.dart'; +import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/models.dart'; import 'package:neon_framework/theme.dart'; import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; import 'package:neon_talk/l10n/localizations.dart'; +import 'package:neon_talk/src/blocs/message_bloc.dart'; import 'package:neon_talk/src/blocs/room.dart'; import 'package:neon_talk/src/widgets/actor_avatar.dart'; import 'package:neon_talk/src/widgets/reactions.dart'; import 'package:neon_talk/src/widgets/read_indicator.dart'; +import 'package:neon_talk/src/widgets/reference_preview.dart'; import 'package:neon_talk/src/widgets/rich_object/deck_card.dart'; import 'package:neon_talk/src/widgets/rich_object/fallback.dart'; import 'package:neon_talk/src/widgets/rich_object/file.dart'; @@ -37,8 +42,10 @@ String getActorDisplayName(TalkLocalizations localizations, spreed.$ChatMessageI /// Renders the [chatMessage] as a rich [TextSpan]. TextSpan buildChatMessage({ required spreed.$ChatMessageInterface chatMessage, + required BuiltList references, + required TextStyle style, + required void Function(String reference) onReferenceClicked, bool isPreview = false, - TextStyle? style, }) { var message = chatMessage.message; if (isPreview) { @@ -66,6 +73,16 @@ TextSpan buildChatMessage({ parts = newParts; } + for (final reference in references) { + final newParts = []; + + for (final part in parts) { + final p = part.split(reference); + newParts.addAll(p.intersperse(reference)); + } + + parts = newParts; + } final children = []; @@ -100,6 +117,29 @@ TextSpan buildChatMessage({ break; } } + for (final reference in references) { + if (reference == part) { + children.add( + WidgetSpan( + child: InkWell( + onTap: () { + onReferenceClicked(reference); + }, + onHover: (_) {}, + child: Text( + part, + style: style.copyWith( + decoration: TextDecoration.underline, + decorationThickness: 2, + ), + ), + ), + ), + ); + match = true; + break; + } + } if (!match) { children.add( @@ -207,8 +247,10 @@ class TalkMessagePreview extends StatelessWidget { ), buildChatMessage( chatMessage: chatMessage, + references: BuiltList(), isPreview: true, - style: Theme.of(context).textTheme.bodyMedium, + style: Theme.of(context).textTheme.bodyMedium!, + onReferenceClicked: context.go, ), ], ), @@ -295,7 +337,9 @@ class TalkSystemMessage extends StatelessWidget { child: RichText( text: buildChatMessage( chatMessage: chatMessage, - style: Theme.of(context).textTheme.labelSmall, + references: BuiltList(), + style: Theme.of(context).textTheme.labelSmall!, + onReferenceClicked: context.go, ), ), ), @@ -381,206 +425,241 @@ class TalkCommentMessage extends StatefulWidget { class _TalkCommentMessageState extends State { bool hoverState = false; bool menuOpen = false; + late final TalkMessageBloc bloc; @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final labelColor = Theme.of(context).colorScheme.inverseSurface.withOpacity(0.7); + void initState() { + super.initState(); - final date = DateTimeUtils.fromSecondsSinceEpoch( - tz.UTC, - widget.chatMessage.timestamp, + bloc = TalkMessageBloc( + chatMessage: widget.chatMessage, + referencesBloc: NeonProvider.of(context), + isParent: widget.isParent, ); - tz.TZDateTime? previousDate; - if (widget.previousChatMessage != null) { - previousDate = DateTimeUtils.fromSecondsSinceEpoch( - tz.UTC, - widget.previousChatMessage!.timestamp, - ); - } + } - final separateMessages = widget.chatMessage.actorId != widget.previousChatMessage?.actorId || - widget.previousChatMessage?.messageType == spreed.MessageType.system || - previousDate == null || - date.difference(previousDate) > const Duration(minutes: 3); - - Widget? displayName; - Widget? avatar; - Widget? time; - if (separateMessages) { - displayName = Text( - getActorDisplayName(TalkLocalizations.of(context), widget.chatMessage), - style: textTheme.labelLarge!.copyWith( - color: labelColor, - ), - ); + @override + void dispose() { + bloc.dispose(); - if (!widget.isParent) { - avatar = TalkActorAvatar( - actorId: widget.chatMessage.actorId, - actorType: widget.chatMessage.actorType, - ); + super.dispose(); + } - time = Tooltip( - message: _dateTimeFormat.format(date.toLocal()), - child: Text( - _timeFormat.format(date.toLocal()), - style: textTheme.labelSmall, - ), - ); - } - } + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: bloc.references, + builder: (context, referencesResult) { + final references = referencesResult.data ?? BuiltMap(); - Widget? parent; - if (widget.chatMessage - case spreed.ChatMessageWithParent( - parent: final p, - messageType: != spreed.MessageType.commentDeleted, - ) when p != null && !widget.isParent) { - parent = TalkParentMessage( - room: widget.room, - parentChatMessage: p, - lastCommonRead: widget.lastCommonRead, - ); - } + final textTheme = Theme.of(context).textTheme; + final labelColor = Theme.of(context).colorScheme.inverseSurface.withOpacity(0.7); - double topMargin; - if (widget.isParent) { - topMargin = 5; - } else if (separateMessages) { - topMargin = 20; - } else { - topMargin = 0; - } + final date = DateTimeUtils.fromSecondsSinceEpoch( + tz.UTC, + widget.chatMessage.timestamp, + ); + tz.TZDateTime? previousDate; + if (widget.previousChatMessage != null) { + previousDate = DateTimeUtils.fromSecondsSinceEpoch( + tz.UTC, + widget.previousChatMessage!.timestamp, + ); + } + + final separateMessages = widget.chatMessage.actorId != widget.previousChatMessage?.actorId || + widget.previousChatMessage?.messageType == spreed.MessageType.system || + previousDate == null || + date.difference(previousDate) > const Duration(minutes: 3); + + Widget? displayName; + Widget? avatar; + Widget? time; + if (separateMessages) { + displayName = Text( + getActorDisplayName(TalkLocalizations.of(context), widget.chatMessage), + style: textTheme.labelLarge!.copyWith( + color: labelColor, + ), + ); - Widget text = Text.rich( - buildChatMessage( - chatMessage: widget.chatMessage, - isPreview: widget.isParent, - style: textTheme.bodyLarge!.copyWith( - color: widget.isParent || widget.chatMessage.messageType == spreed.MessageType.commentDeleted - ? labelColor - : null, - ), - ), - maxLines: widget.isParent ? 1 : null, - overflow: widget.isParent ? TextOverflow.ellipsis : TextOverflow.visible, - ); - if (!widget.isParent && widget.chatMessage.messageType != spreed.MessageType.commentDeleted) { - text = SelectionArea( - child: text, - ); - } - if (widget.chatMessage.messageType == spreed.MessageType.commentDeleted) { - text = Row( - children: [ - Icon( - AdaptiveIcons.cancel, - size: textTheme.bodySmall!.fontSize, - color: labelColor, - ), - const SizedBox( - width: 2.5, + if (!widget.isParent) { + avatar = TalkActorAvatar( + actorId: widget.chatMessage.actorId, + actorType: widget.chatMessage.actorType, + ); + + time = Tooltip( + message: _dateTimeFormat.format(date.toLocal()), + child: Text( + _timeFormat.format(date.toLocal()), + style: textTheme.labelSmall, + ), + ); + } + } + + Widget? parent; + if (widget.chatMessage + case spreed.ChatMessageWithParent( + parent: final p, + messageType: != spreed.MessageType.commentDeleted, + ) when p != null && !widget.isParent) { + parent = TalkParentMessage( + room: widget.room, + parentChatMessage: p, + lastCommonRead: widget.lastCommonRead, + ); + } + + double topMargin; + if (widget.isParent) { + topMargin = 5; + } else if (separateMessages) { + topMargin = 20; + } else { + topMargin = 0; + } + + Widget text = Text.rich( + buildChatMessage( + chatMessage: widget.chatMessage, + isPreview: widget.isParent, + references: references.keys.toBuiltList(), + style: textTheme.bodyLarge!.copyWith( + color: widget.isParent || widget.chatMessage.messageType == spreed.MessageType.commentDeleted + ? labelColor + : null, + ), + onReferenceClicked: context.go, ), - text, - ], - ); - } + maxLines: widget.isParent ? 1 : null, + overflow: widget.isParent ? TextOverflow.ellipsis : TextOverflow.visible, + ); + if (!widget.isParent && widget.chatMessage.messageType != spreed.MessageType.commentDeleted) { + text = SelectionArea( + child: text, + ); + } + if (widget.chatMessage.messageType == spreed.MessageType.commentDeleted) { + text = Row( + children: [ + Icon( + AdaptiveIcons.cancel, + size: textTheme.bodySmall!.fontSize, + color: labelColor, + ), + const SizedBox( + width: 2.5, + ), + text, + ], + ); + } - final account = NeonProvider.of(context); + final account = NeonProvider.of(context); - Widget? readIndicator; - if (widget.lastCommonRead != null && account.username == widget.chatMessage.actorId) { - readIndicator = TalkReadIndicator( - chatMessage: widget.chatMessage, - lastCommonRead: widget.lastCommonRead!, - ); - } + Widget? readIndicator; + if (widget.lastCommonRead != null && account.username == widget.chatMessage.actorId) { + readIndicator = TalkReadIndicator( + chatMessage: widget.chatMessage, + lastCommonRead: widget.lastCommonRead!, + ); + } - Widget message = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Widget message = Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (displayName != null) displayName, - if (time != null) time, - ], - ), - if (parent != null) parent, - text, - if (!widget.isParent && widget.chatMessage.reactions.isNotEmpty) - TalkReactions( - chatMessage: widget.chatMessage, - ), - ] - .intersperse( - const SizedBox( - height: 5, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (displayName != null) displayName, + if (time != null) time, + ], ), - ) - .toList(), - ); - - if (!widget.isParent) { - message = MouseRegion( - onEnter: (_) { - setState(() { - hoverState = true; - }); - }, - onExit: (_) { - setState(() { - hoverState = false; - }); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: (hoverState || menuOpen) ? Theme.of(context).colorScheme.surfaceDim : null, - ), - padding: const EdgeInsets.all(5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(right: 10), - child: SizedBox( - width: 40, - child: avatar, + if (parent != null) parent, + text, + for (final entry in references.entries.take(3)) + if (entry.value.data?.openGraphObject.name != entry.key) + TalkReferencePreview( + url: entry.key, + openGraphObject: entry.value.data?.openGraphObject, ), + if (!widget.isParent && widget.chatMessage.reactions.isNotEmpty) + TalkReactions( + chatMessage: widget.chatMessage, ), - Expanded( - child: message, - ), - Padding( - padding: const EdgeInsets.only(left: 5), - child: SizedBox( - width: 14, - child: readIndicator, + ] + .intersperse( + const SizedBox( + height: 5, ), + ) + .toList(), + ); + + if (!widget.isParent) { + message = MouseRegion( + onEnter: (_) { + setState(() { + hoverState = true; + }); + }, + onExit: (_) { + setState(() { + hoverState = false; + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: (hoverState || menuOpen) ? Theme.of(context).colorScheme.surfaceDim : null, ), - SizedBox.square( - dimension: 32, - child: widget.chatMessage.messageType != spreed.MessageType.commentDeleted && (hoverState || menuOpen) - ? _buildPopupMenuButton( - widget.room, - widget.chatMessage, - ) - : null, + padding: const EdgeInsets.all(5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + width: 40, + child: avatar, + ), + ), + Expanded( + child: message, + ), + Padding( + padding: const EdgeInsets.only(left: 5), + child: SizedBox( + width: 14, + child: readIndicator, + ), + ), + SizedBox.square( + dimension: 32, + child: + widget.chatMessage.messageType != spreed.MessageType.commentDeleted && (hoverState || menuOpen) + ? _buildPopupMenuButton( + widget.room, + widget.chatMessage, + ) + : null, + ), + ], ), - ], - ), - ), - ); - } + ), + ); + } - return Container( - margin: EdgeInsets.only( - top: topMargin, - bottom: 5, - ), - child: message, + return Container( + margin: EdgeInsets.only( + top: topMargin, + bottom: 5, + ), + child: message, + ); + }, ); } diff --git a/packages/neon/neon_talk/lib/src/widgets/reference_preview.dart b/packages/neon/neon_talk/lib/src/widgets/reference_preview.dart new file mode 100644 index 00000000000..ed2279fd5cf --- /dev/null +++ b/packages/neon/neon_talk/lib/src/widgets/reference_preview.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:nextcloud/core.dart' as core; + +/// Widget for displaying a preview of a link reference. +class TalkReferencePreview extends StatelessWidget { + /// Creates a new Talk reference preview. + const TalkReferencePreview({ + required this.url, + required this.openGraphObject, + super.key, + }); + + /// URL of the reference. + final String url; + + /// Open Graph object of the reference. + final core.OpenGraphObject? openGraphObject; + + @override + Widget build(BuildContext context) { + if (openGraphObject != null) { + final thumb = openGraphObject!.thumb; + final title = openGraphObject!.name; + final subtitle = openGraphObject!.description; + + Widget child = Container( + margin: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.replaceAll('\n', ' '), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + if (subtitle != null) + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + url, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ); + + if (thumb != null) { + child = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints.tight(const Size.square(100)), + child: NeonUriImage( + uri: Uri.parse(thumb), + fit: BoxFit.cover, + account: NeonProvider.of(context), + ), + ), + Flexible( + child: child, + ), + ], + ); + } + + return Card( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), + side: BorderSide( + color: Theme.of(context).colorScheme.secondaryContainer, + width: 2, + ), + ), + child: InkWell( + onTap: () => context.go(url), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 100, + ), + child: child, + ), + ), + ); + } else { + return const Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(), + ); + } + } +} diff --git a/packages/neon/neon_talk/test/goldens/message_comment_message_with_references.png b/packages/neon/neon_talk/test/goldens/message_comment_message_with_references.png new file mode 100644 index 0000000000000000000000000000000000000000..7474f344fe9e7622804abbea81f6c87e00bac883 GIT binary patch literal 7051 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 OggoH8zk2V{AN~_^Bg|z0 literal 0 HcmV?d00001 diff --git a/packages/neon/neon_talk/test/goldens/reference_preview.png b/packages/neon/neon_talk/test/goldens/reference_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..ffc91e12388e27de56d770ba7701e6d0ee507109 GIT binary patch literal 4644 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 literal 0 HcmV?d00001 diff --git a/packages/neon/neon_talk/test/goldens/reference_preview_with_thumb.png b/packages/neon/neon_talk/test/goldens/reference_preview_with_thumb.png new file mode 100644 index 0000000000000000000000000000000000000000..c825cb7995c1caf4a21e28260a8a2761378db53b GIT binary patch literal 4647 zcmeI0ZB)`{AIC4v%F2g1*VMH1Or-nFF)dRuR9ttPaYEQxJ5$z!buf2NB~3PjP;qe< zYpzr}KA@;Ob@#A_T9^?j&}NDF0Gmyj4~V5mW+|YEi1Odx&R%u4v%T1>9sX~w^S_>c zzu)KjzPP^E+4l*F?i;sk1OT|>_U-)ufHgq?IH$O-gL|rJr*q)OiTXj}9?+-^n1X*? zsC#f9y24++YuZ-;c>Rpq`_6}S{&NjWpfMko8`V>@eWOudeE$5$5M=THZv}@3yyxfd zUo0eam!MAYx)OI81G%ROC>h;<^4#%Z;Ou05D5{{!8y{b)R;r}q-lj93O(dl=a=GVe) zGY2>UkWs-`UXd_arOX*|D03SFO^i3vk;sMgjEtu`sXqXQHzT8>DvZWWLgU3G#_(`c zmsu@~9v}Bv{*}tsGuNf%=I)G!l-YU}^lWZWsPi#}%A|2vW5=fEQ_sbUdj=N(lpnW? z@kukPhgwLT|A>gK)U?j3Zc}7sHQC}wGMRiemevIZMcHqtDH`s7IUc&kCa9pIA;m1z zYz2T$sj02&VzTU?2v3=xjxeF*=E9XTYb_|>AXS_#YxL9u9Yft(__oZqvLeq+N8e))_7$BQhF)J|O9Qk302$w5q{NiXa80-rf>B{}Y=*SBaWWXr_s~ zMMsMcttdmA8FLTBQx(Gz`-JHag2>&Q*8_0l#&^799s%3Zt0t(?tXN{al`gfP;}C}O z`q%-Y75l6X%UW#DlIa!@io5;A_u(amwvGx<;R$p8k=5>HxwK%=$YJI5Ny|2LHml+< zB>8Kr;LM@cG={;i;{{Na=FFdab=?xOu#U#y46$S4W5dkapb9jL-Cy!Sz1qAOWu z-upU=RB=&*q&c{!l_VPO9a8-ZyI< zZb0ChmLVX+G-+ZKnkb+@t=dpNS?$~;{ImdUts)l+{#W|Q;a zjONRZeT4E)Q(Szf;%&m~Q(iEj%wBiB?9S89k#J7B9vc(c87?Y(8qAn@#4$CLEwCYTH3O&^y5Z=IeJD?u-(UUZZP@Ki7=BP>NtT5CywPcs+7egl?mv2COnSin z>XKDoqOb9sQ4BPwbyuSrRp!|?gy)DPRP~JcCU5R2oIk&R^86&PfOy=ZY0Yi#ubf2r zP&&gT5ZgM*W)l1Y6?6CmyXa4zIYK0`T{8^rc?BUruO hg3V}U^$W{sU;lmiT|zXeVKY1kaPJcKHtsok`ZsWu+Ts8J literal 0 HcmV?d00001 diff --git a/packages/neon/neon_talk/test/message_bloc_test.dart b/packages/neon/neon_talk/test/message_bloc_test.dart new file mode 100644 index 00000000000..15d26d913e6 --- /dev/null +++ b/packages/neon/neon_talk/test/message_bloc_test.dart @@ -0,0 +1,75 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:neon_talk/src/blocs/message_bloc.dart'; +import 'package:nextcloud/core.dart' as core; +import 'package:rxdart/rxdart.dart'; + +import 'testing.dart'; + +void main() { + registerFallbackValue(BuiltList()); + + late BehaviorSubject>> references; + late ReferencesBloc referencesBloc; + late TalkMessageBloc bloc; + + setUp(() async { + references = BehaviorSubject(); + referencesBloc = MockReferencesBloc(); + when(() => referencesBloc.referenceRegex).thenAnswer( + (_) => BehaviorSubject.seeded(Result.success(RegExp('[a-z]+'))), + ); + when(() => referencesBloc.references).thenAnswer((_) => references); + + final message = MockChatMessage(); + when(() => message.message).thenReturn('a b c'); + + bloc = TalkMessageBloc( + chatMessage: message, + referencesBloc: referencesBloc, + isParent: false, + ); + }); + + tearDown(() async { + await references.close(); + bloc.dispose(); + }); + + test('Resolves references', () async { + verify(() => referencesBloc.loadReferences(BuiltList(['a', 'b', 'c']))).called(1); + + final openGraphObject = MockOpenGraphObject(); + when(() => openGraphObject.name).thenReturn('name'); + when(() => openGraphObject.link).thenReturn('/link'); + + final reference = MockReference(); + when(() => reference.openGraphObject).thenReturn(openGraphObject); + + references.add( + BuiltMap({ + for (final key in ['a', 'b']) key: Result.success(reference), + }), + ); + + expect( + bloc.references, + emitsInOrder(>>[ + BuiltMap({ + 'a': Result.loading(), + 'b': Result.loading(), + 'c': Result.loading(), + }), + BuiltMap({ + 'a': Result.success(reference), + 'b': Result.success(reference), + }), + ]), + ); + + await Future.delayed(const Duration(milliseconds: 1)); + }); +} diff --git a/packages/neon/neon_talk/test/message_test.dart b/packages/neon/neon_talk/test/message_test.dart index 1869d7f72f2..2672fbace26 100644 --- a/packages/neon/neon_talk/test/message_test.dart +++ b/packages/neon/neon_talk/test/message_test.dart @@ -15,6 +15,7 @@ import 'package:neon_talk/src/widgets/actor_avatar.dart'; import 'package:neon_talk/src/widgets/message.dart'; import 'package:neon_talk/src/widgets/reactions.dart'; import 'package:neon_talk/src/widgets/read_indicator.dart'; +import 'package:neon_talk/src/widgets/reference_preview.dart'; import 'package:neon_talk/src/widgets/rich_object/deck_card.dart'; import 'package:neon_talk/src/widgets/rich_object/fallback.dart'; import 'package:neon_talk/src/widgets/rich_object/file.dart'; @@ -43,6 +44,7 @@ Widget wrapWidget({ void main() { late spreed.Room room; + late ReferencesBloc referencesBloc; setUpAll(() { FakeNeonStorage.setup(); @@ -55,6 +57,10 @@ void main() { setUp(() { room = MockRoom(); + + referencesBloc = MockReferencesBloc(); + when(() => referencesBloc.referenceRegex).thenAnswer((_) => BehaviorSubject.seeded(Result.success(null))); + when(() => referencesBloc.references).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); }); group('getActorDisplayName', () { @@ -236,6 +242,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkMessage( room: room, @@ -313,6 +320,7 @@ void main() { wrapWidget( providers: [ Provider.value(value: account), + NeonProvider.value(value: referencesBloc), ], child: TalkParentMessage( room: room, @@ -357,6 +365,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -410,6 +419,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -457,6 +467,7 @@ void main() { wrapWidget( providers: [ Provider.value(value: account), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -493,6 +504,7 @@ void main() { wrapWidget( providers: [ Provider.value(value: account), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -552,6 +564,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -570,6 +583,87 @@ void main() { ); }); + testWidgets('With references', (tester) async { + when(() => referencesBloc.referenceRegex).thenAnswer( + (_) => BehaviorSubject.seeded(Result.success(RegExp('[a-z]+'))), + ); + + final openGraphObject = MockOpenGraphObject(); + when(() => openGraphObject.name).thenReturn('name'); + when(() => openGraphObject.link).thenReturn('/link'); + + final reference = MockReference(); + when(() => reference.openGraphObject).thenReturn(openGraphObject); + + final brokenOpenGraphObject = MockOpenGraphObject(); + when(() => brokenOpenGraphObject.name).thenReturn('c'); + when(() => brokenOpenGraphObject.link).thenReturn('/link'); + + final brokenReference = MockReference(); + when(() => brokenReference.openGraphObject).thenReturn(brokenOpenGraphObject); + + when(() => referencesBloc.references).thenAnswer( + (_) => BehaviorSubject.seeded( + BuiltMap({ + 'a': Result.success(reference), + 'b': Result.success(reference), + 'c': Result.success(brokenReference), + }), + ), + ); + + final account = MockAccount(); + when(() => account.id).thenReturn(''); + when(() => account.client).thenReturn(NextcloudClient(Uri.parse(''))); + + final chatMessage = MockChatMessageWithParent(); + when(() => chatMessage.id).thenReturn(0); + when(() => chatMessage.timestamp).thenReturn(0); + when(() => chatMessage.actorId).thenReturn('test'); + when(() => chatMessage.actorType).thenReturn(spreed.ActorType.users); + when(() => chatMessage.actorDisplayName).thenReturn('test'); + when(() => chatMessage.messageType).thenReturn(spreed.MessageType.comment); + when(() => chatMessage.message).thenReturn('a b c'); + when(() => chatMessage.reactions).thenReturn(BuiltMap()); + when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); + + final roomBloc = MockRoomBloc(); + when(() => roomBloc.reactions).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); + + await tester.pumpWidgetWithAccessibility( + wrapWidget( + providers: [ + Provider.value(value: account), + NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), + ], + child: TalkCommentMessage( + room: room, + chatMessage: chatMessage, + lastCommonRead: null, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TalkReferencePreview), findsExactly(2)); + for (final url in ['a', 'b']) { + expect( + find.byWidgetPredicate( + (widget) => + widget is TalkReferencePreview && widget.url == url && widget.openGraphObject == openGraphObject, + ), + findsOne, + ); + } + + await expectLater( + find.byType(TalkCommentMessage).first, + matchesGoldenFile('goldens/message_comment_message_with_references.png'), + ); + }); + group('Separate messages', () { testWidgets('Actor', (tester) async { final account = MockAccount(); @@ -599,6 +693,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -649,6 +744,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -698,6 +794,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -761,6 +858,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -804,6 +902,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -837,6 +936,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -870,6 +970,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -907,6 +1008,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -938,6 +1040,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -971,6 +1074,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -1008,6 +1112,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -1041,6 +1146,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: roomBloc), + NeonProvider.value(value: referencesBloc), ], child: TalkCommentMessage( room: room, @@ -1185,10 +1291,21 @@ void main() { when(() => chatMessage.message).thenReturn('123\n456'); when(() => chatMessage.messageParameters).thenReturn(BuiltMap()); - var span = buildChatMessage(chatMessage: chatMessage).children!.single as TextSpan; + var span = buildChatMessage( + chatMessage: chatMessage, + references: BuiltList(), + style: const TextStyle(), + onReferenceClicked: (_) {}, + ).children!.single as TextSpan; expect(span.text, '123\n456'); - span = buildChatMessage(chatMessage: chatMessage, isPreview: true).children!.single as TextSpan; + span = buildChatMessage( + chatMessage: chatMessage, + references: BuiltList(), + style: const TextStyle(), + onReferenceClicked: (_) {}, + isPreview: true, + ).children!.single as TextSpan; expect(span.text, '123 456'); }); @@ -1209,7 +1326,12 @@ void main() { }), ); - final spans = buildChatMessage(chatMessage: chatMessage).children!; + final spans = buildChatMessage( + chatMessage: chatMessage, + references: BuiltList(), + style: const TextStyle(), + onReferenceClicked: (_) {}, + ).children!; expect((spans.single as TextSpan).text, 'test'); }); } @@ -1229,7 +1351,12 @@ void main() { }), ); - final spans = buildChatMessage(chatMessage: chatMessage).children!; + final spans = buildChatMessage( + chatMessage: chatMessage, + references: BuiltList(), + style: const TextStyle(), + onReferenceClicked: (_) {}, + ).children!; expect(spans, hasLength(3)); expect((spans[0] as WidgetSpan).child, isA()); expect((spans[1] as TextSpan).text, '\n'); @@ -1257,7 +1384,12 @@ void main() { }), ); - final spans = buildChatMessage(chatMessage: chatMessage).children!; + final spans = buildChatMessage( + chatMessage: chatMessage, + references: BuiltList(), + style: const TextStyle(), + onReferenceClicked: (_) {}, + ).children!; expect(spans, hasLength(5)); expect((spans[0] as TextSpan).text, '123 '); expect((spans[1] as WidgetSpan).child, isA()); diff --git a/packages/neon/neon_talk/test/reference_preview_test.dart b/packages/neon/neon_talk/test/reference_preview_test.dart new file mode 100644 index 00000000000..b64e9b5b0ae --- /dev/null +++ b/packages/neon/neon_talk/test/reference_preview_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/testing.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:neon_talk/src/widgets/reference_preview.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:provider/provider.dart'; + +import 'testing.dart'; + +void main() { + setUpAll(() { + FakeNeonStorage.setup(); + }); + + testWidgets('Loading', (tester) async { + final router = MockGoRouter(); + + await tester.pumpWidgetWithAccessibility( + TestApp( + router: router, + child: const TalkReferencePreview( + url: '/link', + openGraphObject: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOne); + }); + + testWidgets('Default', (tester) async { + final account = MockAccount(); + when(() => account.client).thenReturn(NextcloudClient(Uri())); + + final openGraphObject = MockOpenGraphObject(); + when(() => openGraphObject.name).thenReturn('name'); + when(() => openGraphObject.link).thenReturn('/link'); + + final router = MockGoRouter(); + + await tester.pumpWidgetWithAccessibility( + TestApp( + router: router, + providers: [ + Provider.value(value: account), + ], + child: TalkReferencePreview( + url: '/link', + openGraphObject: openGraphObject, + ), + ), + ); + + expect(find.byType(NeonUriImage), findsNothing); + expect(find.text('name'), findsOne); + expect(find.text('description'), findsNothing); + expect(find.text('/link'), findsOne); + await expectLater(find.byType(TalkReferencePreview), matchesGoldenFile('goldens/reference_preview.png')); + }); + + testWidgets('With thumb', (tester) async { + final account = MockAccount(); + when(() => account.client).thenReturn(NextcloudClient(Uri())); + + final openGraphObject = MockOpenGraphObject(); + when(() => openGraphObject.thumb).thenReturn(''); + when(() => openGraphObject.name).thenReturn('name'); + when(() => openGraphObject.link).thenReturn('/link'); + + final router = MockGoRouter(); + + await tester.pumpWidgetWithAccessibility( + TestApp( + router: router, + providers: [ + Provider.value(value: account), + ], + child: TalkReferencePreview( + url: '/link', + openGraphObject: openGraphObject, + ), + ), + ); + + expect(find.byType(NeonUriImage), findsOne); + expect(find.text('name'), findsOne); + expect(find.text('description'), findsNothing); + expect(find.text('/link'), findsOne); + await expectLater(find.byType(TalkReferencePreview), matchesGoldenFile('goldens/reference_preview_with_thumb.png')); + }); + + testWidgets('With description', (tester) async { + final openGraphObject = MockOpenGraphObject(); + when(() => openGraphObject.name).thenReturn('name'); + when(() => openGraphObject.description).thenReturn('description'); + + final router = MockGoRouter(); + + await tester.pumpWidgetWithAccessibility( + TestApp( + router: router, + child: TalkReferencePreview( + url: '/link', + openGraphObject: openGraphObject, + ), + ), + ); + + expect(find.byType(NeonUriImage), findsNothing); + expect(find.text('name'), findsOne); + expect(find.text('description'), findsOne); + expect(find.text('/link'), findsOne); + await expectLater( + find.byType(TalkReferencePreview), + matchesGoldenFile('goldens/reference_preview_with_description.png'), + ); + }); +} diff --git a/packages/neon/neon_talk/test/room_page_test.dart b/packages/neon/neon_talk/test/room_page_test.dart index 976f3629d21..adcf7935ce1 100644 --- a/packages/neon/neon_talk/test/room_page_test.dart +++ b/packages/neon/neon_talk/test/room_page_test.dart @@ -27,15 +27,14 @@ import 'testing.dart'; void main() { late spreed.Room room; late TalkRoomBloc bloc; + late ReferencesBloc referencesBloc; setUpAll(() { KeyboardVisibilityTesting.setVisibilityForTesting(true); tzdata.initializeTimeZones(); tz.setLocalLocation(tz.getLocation('Europe/Berlin')); - }); - setUp(() { FakeNeonStorage.setup(); }); @@ -55,6 +54,10 @@ void main() { .thenAnswer((_) => BehaviorSubject.seeded(Result.success(BuiltList()))); when(() => bloc.lastCommonRead).thenAnswer((_) => BehaviorSubject.seeded(0)); when(() => bloc.replyTo).thenAnswer((_) => BehaviorSubject.seeded(null)); + + referencesBloc = MockReferencesBloc(); + when(() => referencesBloc.referenceRegex).thenAnswer((_) => BehaviorSubject.seeded(Result.success(null))); + when(() => referencesBloc.references).thenAnswer((_) => BehaviorSubject.seeded(BuiltMap())); }); testWidgets('Status message', (tester) async { @@ -168,6 +171,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: bloc), + NeonProvider.value(value: referencesBloc), ], child: const TalkRoomPage(), ), @@ -231,6 +235,7 @@ void main() { providers: [ Provider.value(value: account), NeonProvider.value(value: bloc), + NeonProvider.value(value: referencesBloc), ], child: const TalkRoomPage(), ), diff --git a/packages/neon/neon_talk/test/testing.dart b/packages/neon/neon_talk/test/testing.dart index ed84c061140..3776ef6389b 100644 --- a/packages/neon/neon_talk/test/testing.dart +++ b/packages/neon/neon_talk/test/testing.dart @@ -1,6 +1,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:neon_talk/src/blocs/room.dart'; import 'package:neon_talk/src/blocs/talk.dart'; +import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/spreed.dart' as spreed; class MockRoom extends Mock implements spreed.Room {} @@ -15,6 +16,10 @@ class MockRoomBloc extends Mock implements TalkRoomBloc {} class MockTalkBloc extends Mock implements TalkBloc {} +class MockReference extends Mock implements core.Reference {} + +class MockOpenGraphObject extends Mock implements core.OpenGraphObject {} + Map getRoom({ int? id, String? token,