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 00000000000..7474f344fe9 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/message_comment_message_with_references.png differ 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 00000000000..ffc91e12388 Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/reference_preview.png differ diff --git a/packages/neon/neon_talk/test/goldens/reference_preview_with_description.png b/packages/neon/neon_talk/test/goldens/reference_preview_with_description.png new file mode 100644 index 00000000000..e93e9a4091c Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/reference_preview_with_description.png differ 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 00000000000..c825cb7995c Binary files /dev/null and b/packages/neon/neon_talk/test/goldens/reference_preview_with_thumb.png differ 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, 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', + ]), + ); + }); +}