Skip to content

Commit

Permalink
feat(talk_app): Add reactions overview dialog
Browse files Browse the repository at this point in the history
Signed-off-by: provokateurin <[email protected]>
  • Loading branch information
provokateurin committed Nov 2, 2024
1 parent 3ae067c commit ececdc4
Show file tree
Hide file tree
Showing 18 changed files with 366 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/neon_framework/packages/talk_app/lib/l10n/en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
}
}
},
"reactions": "Reactions",
"reactionsAll": "All",
"reactionsSeeAll": "See all reactions",
"reactionsAddNew": "Add a new reaction",
"reactionsLoading": "Loading reactions",
"roomsCreateNew": "Create new room"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ abstract class TalkLocalizations {
/// **'Last edited by {name} at {time}'**
String roomMessageLastEdited(String name, String time);

/// No description provided for @reactions.
///
/// In en, this message translates to:
/// **'Reactions'**
String get reactions;

/// No description provided for @reactionsAll.
///
/// In en, this message translates to:
/// **'All'**
String get reactionsAll;

/// No description provided for @reactionsSeeAll.
///
/// In en, this message translates to:
/// **'See all reactions'**
String get reactionsSeeAll;

/// No description provided for @reactionsAddNew.
///
/// In en, this message translates to:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ class TalkLocalizationsEn extends TalkLocalizations {
return 'Last edited by $name at $time';
}

@override
String get reactions => 'Reactions';

@override
String get reactionsAll => 'All';

@override
String get reactionsSeeAll => 'See all reactions';

@override
String get reactionsAddNew => 'Add a new reaction';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ extension $ChatMessageInterfaceHelpers on spreed.$ChatMessageInterface {
lastEditTimestamp != null ? DateTimeUtils.fromSecondsSinceEpoch(tz.local, lastEditTimestamp!) : null;
}

/// Helper extension for [spreed.$ReactionInterface]
extension $ReactionInterfaceHelpers on spreed.$ReactionInterface {
/// Parsed equivalent of [timestamp].
tz.TZDateTime get parsedTimestamp => DateTimeUtils.fromSecondsSinceEpoch(tz.local, timestamp);
}

/// Returns if the Talk [feature] is supported on the instance.
bool hasFeature(BuildContext context, String feature) {
final capabilitiesBloc = NeonProvider.of<CapabilitiesBloc>(context);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:intersperse/intersperse.dart';
import 'package:neon_framework/utils.dart';
import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/spreed.dart' as spreed;
import 'package:talk_app/l10n/localizations.dart';
import 'package:talk_app/src/blocs/room.dart';
import 'package:talk_app/src/widgets/reactions_overview_dialog.dart';

/// Widget for displaying the current reactions on a chat message including the ability to add and remove reactions.
class TalkReactions extends StatelessWidget {
Expand Down Expand Up @@ -100,6 +102,34 @@ class TalkReactions extends StatelessWidget {
),
);

if (chatMessage.reactions.isNotEmpty) {
children.add(
ActionChip(
shape: shape,
avatar: Icon(
MdiIcons.heartOutline,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 16,
),
label: const SizedBox(),
padding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(vertical: -2.5),
tooltip: TalkLocalizations.of(context).reactionsSeeAll,
onPressed: () async {
await showDialog<void>(
context: context,
builder: (context) => NeonProvider.value(
value: bloc,
child: TalkReactionsOverviewDialog(
chatMessage: chatMessage,
),
),
);
},
),
);
}

return Row(
mainAxisSize: MainAxisSize.min,
children: children
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:neon_framework/utils.dart';
import 'package:neon_framework/widgets.dart';
import 'package:nextcloud/spreed.dart' as spreed;
import 'package:talk_app/l10n/localizations.dart';
import 'package:talk_app/src/blocs/room.dart';
import 'package:talk_app/src/utils/helpers.dart';
import 'package:talk_app/src/widgets/actor_avatar.dart';

/// Dialog that displays all reactions of all users for a particular chat message.
class TalkReactionsOverviewDialog extends StatefulWidget {
/// Creates a new [TalkReactionsOverviewDialog].
const TalkReactionsOverviewDialog({
required this.chatMessage,
super.key,
});

/// The chat message to show reactions for.
final spreed.$ChatMessageInterface chatMessage;

@override
State<TalkReactionsOverviewDialog> createState() => _TalkReactionsOverviewDialogState();
}

class _TalkReactionsOverviewDialogState extends State<TalkReactionsOverviewDialog> {
late final TalkRoomBloc bloc;

@override
void initState() {
super.initState();

bloc = NeonProvider.of<TalkRoomBloc>(context);
bloc.loadReactions(widget.chatMessage);
}

@override
Widget build(BuildContext context) {
final localizations = TalkLocalizations.of(context);

return NeonDialog(
title: Text(localizations.reactions),
content: DefaultTabController(
length: widget.chatMessage.reactions.length + 1,
child: Scaffold(
appBar: TabBar(
tabs: [
Tab(
child: Text('${localizations.reactionsAll} ${widget.chatMessage.reactions.values.sum}'),
),
for (final entry in widget.chatMessage.reactions.entries)
Tab(
child: Text('${entry.key} ${entry.value}'),
),
],
),
body: StreamBuilder(
stream: bloc.reactions,
builder: (context, reactionsSnapshot) {
final children = <Widget>[];

final allReactions = reactionsSnapshot.data?[widget.chatMessage.id];
if (allReactions != null) {
children.add(
ListView(
children: [
for (final entry in allReactions.entries)
for (final reaction in entry.value) buildReaction(entry.key, reaction),
],
),
);
} else {
children.add(const CircularProgressIndicator());
}

for (final emoji in widget.chatMessage.reactions.keys) {
final reactions = allReactions?[emoji];
if (reactions != null) {
children.add(
ListView(
children: [
for (final reaction in reactions) buildReaction(emoji, reaction),
],
),
);
} else {
children.add(const CircularProgressIndicator());
}
}

return TabBarView(
children: children,
);
},
),
),
),
);
}

Widget buildReaction(String emoji, spreed.$ReactionInterface reaction) {
return ListTile(
leading: TalkActorAvatar(
actorId: reaction.actorId,
actorType: reaction.actorType,
),
title: Text(
reaction.actorDisplayName,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(DateFormat.yMd().add_jm().format(reaction.parsedTimestamp)),
trailing: Text(
emoji,
style: const TextStyle(
fontSize: 20,
),
),
);
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import 'package:built_collection/built_collection.dart';
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/utils.dart';
import 'package:nextcloud/spreed.dart' as spreed;
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
import 'package:talk_app/l10n/localizations.dart';
import 'package:talk_app/src/blocs/room.dart';
import 'package:talk_app/src/widgets/actor_avatar.dart';
import 'package:talk_app/src/widgets/reactions_overview_dialog.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;

import 'testing.dart';

void main() {
late spreed.ChatMessage chatMessage;
late TalkRoomBloc bloc;

setUpAll(() {
tzdata.initializeTimeZones();
tz.setLocalLocation(tz.getLocation('Europe/Berlin'));

FakeNeonStorage.setup();
});

setUp(() {
chatMessage = MockChatMessage();
when(() => chatMessage.id).thenReturn(0);
when(() => chatMessage.reactions).thenReturn(BuiltMap({'😀': 1, '😊': 2}));
when(() => chatMessage.systemMessage).thenReturn('');

final reaction1 = MockReaction();
when(() => reaction1.actorType).thenReturn(spreed.ActorType.users);
when(() => reaction1.actorId).thenReturn('user1');
when(() => reaction1.actorDisplayName).thenReturn('User One');
when(() => reaction1.timestamp).thenReturn(60);

final reaction2 = MockReaction();
when(() => reaction2.actorType).thenReturn(spreed.ActorType.users);
when(() => reaction2.actorId).thenReturn('user2');
when(() => reaction2.actorDisplayName).thenReturn('User Two');
when(() => reaction2.timestamp).thenReturn(120);

final reaction3 = MockReaction();
when(() => reaction3.actorType).thenReturn(spreed.ActorType.users);
when(() => reaction3.actorId).thenReturn('user3');
when(() => reaction3.actorDisplayName).thenReturn('User Three');
when(() => reaction3.timestamp).thenReturn(180);

bloc = MockRoomBloc();
when(() => bloc.reactions).thenAnswer(
(_) => BehaviorSubject.seeded(
BuiltMap({
0: BuiltMap<String, BuiltList<spreed.Reaction>>({
'😀': BuiltList<spreed.Reaction>([reaction1]),
'😊': BuiltList<spreed.Reaction>([reaction2, reaction3]),
}),
}),
),
);
});

testWidgets('Displays reactions', (tester) async {
final account = MockAccount();

await tester.pumpWidgetWithAccessibility(
TestApp(
localizationsDelegates: TalkLocalizations.localizationsDelegates,
supportedLocales: TalkLocalizations.supportedLocales,
providers: [
NeonProvider<TalkRoomBloc>.value(value: bloc),
Provider<Account>.value(value: account),
],
child: TalkReactionsOverviewDialog(
chatMessage: chatMessage,
),
),
);
await tester.pumpAndSettle();
expect(find.text('All 3'), findsOne);

expect(find.byType(ListTile), findsExactly(3));
expect(find.byType(TalkActorAvatar), findsExactly(3));
expect(find.text('User One'), findsOne);
expect(find.text('1/1/1970 1:01 AM'), findsOne);
expect(find.text('User Two'), findsOne);
expect(find.text('1/1/1970 1:02 AM'), findsOne);
expect(find.text('User Three'), findsOne);
expect(find.text('1/1/1970 1:03 AM'), findsOne);
expect(find.text('😀'), findsOne);
expect(find.text('😊'), findsExactly(2));
await expectLater(
find.byType(TalkReactionsOverviewDialog),
matchesGoldenFile('goldens/reactions_overview_dialog_all.png'),
);

await tester.tap(find.text('😀 1'));
await tester.pumpAndSettle();

expect(find.byType(ListTile), findsOne);
expect(find.byType(TalkActorAvatar), findsOne);
expect(find.text('User One'), findsOne);
expect(find.text('1/1/1970 1:01 AM'), findsOne);
expect(find.text('User Two'), findsNothing);
expect(find.text('1/1/1970 1:02 AM'), findsNothing);
expect(find.text('User Three'), findsNothing);
expect(find.text('1/1/1970 1:03 AM'), findsNothing);
expect(find.text('😀'), findsOne);
expect(find.text('😊'), findsNothing);
await expectLater(
find.byType(TalkReactionsOverviewDialog),
matchesGoldenFile('goldens/reactions_overview_dialog_single.png'),
);

await tester.tap(find.text('😊 2'));
await tester.pumpAndSettle();

expect(find.byType(ListTile), findsExactly(2));
expect(find.byType(TalkActorAvatar), findsExactly(2));
expect(find.text('User One'), findsNothing);
expect(find.text('1/1/1970 1:01 AM'), findsNothing);
expect(find.text('User Two'), findsOne);
expect(find.text('1/1/1970 1:02 AM'), findsOne);
expect(find.text('User Three'), findsOne);
expect(find.text('1/1/1970 1:03 AM'), findsOne);
expect(find.text('😀'), findsNothing);
expect(find.text('😊'), findsExactly(2));
await expectLater(
find.byType(TalkReactionsOverviewDialog),
matchesGoldenFile('goldens/reactions_overview_dialog_multiple.png'),
);
});
}
Loading

0 comments on commit ececdc4

Please sign in to comment.