Skip to content

Commit 92b51b3

Browse files
committed
feat(dm): wire full emoji picker for custom reactions
The "+" affordance in the DM reaction long-press row returned ReactionPickerResult(openFullPicker: true) into a no-op caller stub — tapping it dismissed the sheet and did nothing (#4710). Wire it to pro_image_editor's built-in EmojiEditor (already a direct dependency for the video editor) via a new FullReactionEmojiPickerSheet, shown as a Divine dark-styled bottom sheet. The picked emoji flows through the existing ConversationReactionToggled path unchanged — no new dependency and no repository/protocol work, since the NIP-17 wrapped-reaction plumbing already accepts arbitrary emoji. This supersedes the earlier "temporarily remove the + button" approach per review on #4711, and clears the dead openFullPicker seam and the stale TODO(#4633) breadcrumbs by making the path live. Closes #4710
1 parent ff9356f commit 92b51b3

6 files changed

Lines changed: 194 additions & 14 deletions

File tree

mobile/lib/screens/inbox/conversation/conversation_view.dart

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -378,21 +378,13 @@ class _MessageList extends StatelessWidget {
378378
if (!context.mounted) return;
379379

380380
if (result.emoji != null) {
381-
context.read<ConversationReactionsCubit>().add(
382-
ConversationReactionToggled(
383-
conversationId: message.conversationId,
384-
messageId: message.id,
385-
messageAuthorPubkey: message.senderPubkey,
386-
emoji: result.emoji!,
387-
),
388-
);
381+
_toggleReaction(context, message, result.emoji!);
389382
return;
390383
}
391384
if (result.openFullPicker) {
392-
// Full picker integration is staged for v1 by triggering the
393-
// emoji_picker_flutter sheet. Caller-side gating keeps this
394-
// off the critical path while the dependency is wired in.
395-
// TODO(#4633): wire full emoji_picker_flutter sheet.
385+
final emoji = await FullReactionEmojiPickerSheet.show(context: context);
386+
if (emoji == null || !context.mounted) return;
387+
_toggleReaction(context, message, emoji);
396388
return;
397389
}
398390
final action = result.action;
@@ -417,6 +409,17 @@ class _MessageList extends StatelessWidget {
417409
}
418410
}
419411

412+
void _toggleReaction(BuildContext context, DmMessage message, String emoji) {
413+
context.read<ConversationReactionsCubit>().add(
414+
ConversationReactionToggled(
415+
conversationId: message.conversationId,
416+
messageId: message.id,
417+
messageAuthorPubkey: message.senderPubkey,
418+
emoji: emoji,
419+
),
420+
);
421+
}
422+
420423
@override
421424
Widget build(BuildContext context) {
422425
return ListView.builder(
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// ABOUTME: Full emoji picker bottom sheet for DM custom reactions. Wraps
2+
// ABOUTME: pro_image_editor's EmojiEditor in a Divine dark-styled sheet.
3+
4+
import 'package:divine_ui/divine_ui.dart';
5+
import 'package:flutter/material.dart';
6+
import 'package:pro_image_editor/pro_image_editor.dart';
7+
8+
/// Bottom sheet that lets the user pick any emoji as a DM reaction.
9+
///
10+
/// Wraps [EmojiEditor] from `pro_image_editor` — already a direct
11+
/// dependency for the video editor — so the full emoji set is available
12+
/// beyond the six quick reactions, without adding a new picker package.
13+
/// The picked emoji flows through the same [ConversationReactionToggled]
14+
/// path as a quick reaction.
15+
class FullReactionEmojiPickerSheet {
16+
/// Visible height of the picker body, excluding the keyboard inset.
17+
static const double _bodyHeight = 320;
18+
19+
/// Dark-mode editor configuration. Matches the picker grid background to
20+
/// the surrounding sheet so the surface reads as a single dark panel.
21+
static const _editorConfigs = ProImageEditorConfigs(
22+
emojiEditor: EmojiEditorConfigs(
23+
style: EmojiEditorStyle(
24+
backgroundColor: VineTheme.surfaceBackground,
25+
),
26+
),
27+
);
28+
29+
/// Shows the picker and resolves to the selected emoji, or `null` if the
30+
/// sheet was dismissed without a choice.
31+
static Future<String?> show({required BuildContext context}) async {
32+
final layer = await showModalBottomSheet<EmojiLayer>(
33+
context: context,
34+
backgroundColor: VineTheme.surfaceBackground,
35+
isScrollControlled: true,
36+
shape: const RoundedRectangleBorder(
37+
borderRadius: BorderRadius.vertical(
38+
top: Radius.circular(VineTheme.bottomSheetBorderRadius),
39+
),
40+
),
41+
builder: (sheetContext) {
42+
return SafeArea(
43+
top: false,
44+
child: ConstrainedBox(
45+
constraints: BoxConstraints(
46+
maxHeight:
47+
_bodyHeight + MediaQuery.viewInsetsOf(sheetContext).bottom,
48+
),
49+
child: const EmojiEditor(configs: _editorConfigs),
50+
),
51+
);
52+
},
53+
);
54+
return layer?.emoji;
55+
}
56+
}

mobile/lib/screens/inbox/conversation/widgets/widgets.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export 'collaborator_invite_card.dart';
22
export 'conversation_app_bar.dart';
33
export 'empty_conversation.dart';
4+
export 'full_reaction_emoji_picker_sheet.dart';
45
export 'message_actions_sheet.dart';
56
export 'message_bubble.dart';
67
export 'message_input_bar.dart';

mobile/test/screens/inbox/conversation/conversation_view_test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'package:openvine/providers/user_profile_providers.dart';
2121
import 'package:openvine/screens/inbox/conversation/conversation_view.dart';
2222
import 'package:openvine/screens/inbox/conversation/widgets/widgets.dart';
2323
import 'package:openvine/services/video_event_service.dart';
24+
import 'package:pro_image_editor/pro_image_editor.dart';
2425
import 'package:visibility_detector/visibility_detector.dart';
2526

2627
import '../../../builders/video_event_builder.dart';
@@ -1208,6 +1209,26 @@ void main() {
12081209
);
12091210
},
12101211
);
1212+
1213+
// Closes #4710: the "+" affordance was a no-op stub. Proves the
1214+
// long-press → "+" path now dismisses the reaction overlay and
1215+
// opens the full emoji picker.
1216+
testWidgets(
1217+
'tapping "+" in the reaction row opens the full emoji picker',
1218+
(tester) async {
1219+
await pumpWithMessage(tester);
1220+
1221+
await tester.longPress(find.text('Hello there!'));
1222+
await tester.pumpAndSettle();
1223+
1224+
await tester.tap(
1225+
find.bySemanticsLabel(l10n.dmReactionAddCustomA11yLabel),
1226+
);
1227+
await tester.pumpAndSettle();
1228+
1229+
expect(find.byType(EmojiEditor), findsOneWidget);
1230+
},
1231+
);
12111232
});
12121233
});
12131234
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// ABOUTME: Widget tests for FullReactionEmojiPickerSheet.
2+
// ABOUTME: Verifies the sheet mounts the emoji picker and resolves its result.
3+
4+
import 'dart:async';
5+
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:openvine/screens/inbox/conversation/widgets/full_reaction_emoji_picker_sheet.dart';
9+
import 'package:pro_image_editor/pro_image_editor.dart';
10+
11+
import '../../../../helpers/test_provider_overrides.dart';
12+
13+
void main() {
14+
group('FullReactionEmojiPickerSheet', () {
15+
testWidgets('mounts the emoji picker when shown', (tester) async {
16+
await tester.pumpWidget(
17+
testMaterialApp(
18+
home: Builder(
19+
builder: (context) {
20+
return Scaffold(
21+
body: TextButton(
22+
onPressed: () {
23+
unawaited(
24+
FullReactionEmojiPickerSheet.show(context: context),
25+
);
26+
},
27+
child: const Text('open'),
28+
),
29+
);
30+
},
31+
),
32+
),
33+
);
34+
35+
await tester.tap(find.text('open'));
36+
await tester.pumpAndSettle();
37+
38+
expect(find.byType(EmojiEditor), findsOneWidget);
39+
});
40+
41+
testWidgets('resolves to null when dismissed without a choice', (
42+
tester,
43+
) async {
44+
String? selected;
45+
var completed = false;
46+
await tester.pumpWidget(
47+
testMaterialApp(
48+
home: Builder(
49+
builder: (context) {
50+
return Scaffold(
51+
body: TextButton(
52+
onPressed: () async {
53+
selected = await FullReactionEmojiPickerSheet.show(
54+
context: context,
55+
);
56+
completed = true;
57+
},
58+
child: const Text('open'),
59+
),
60+
);
61+
},
62+
),
63+
),
64+
);
65+
66+
await tester.tap(find.text('open'));
67+
await tester.pumpAndSettle();
68+
69+
// Dismiss the sheet without selecting an emoji.
70+
Navigator.of(tester.element(find.byType(EmojiEditor))).pop();
71+
await tester.pumpAndSettle();
72+
73+
expect(completed, isTrue);
74+
expect(selected, isNull);
75+
});
76+
});
77+
}

mobile/test/screens/inbox/conversation/widgets/reaction_picker_overlay_test.dart

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,37 @@ void main() {
6969
expect(find.text('Copy text'), findsNothing);
7070
});
7171

72-
testWidgets('dismisses after selecting the full-picker affordance', (
72+
testWidgets('"+" affordance dismisses and pops openFullPicker', (
7373
tester,
7474
) async {
75-
await openOverlay(tester);
75+
ReactionPickerResult? result;
76+
await tester.pumpWidget(
77+
testMaterialApp(
78+
home: Builder(
79+
builder: (context) {
80+
return Scaffold(
81+
body: TextButton(
82+
onPressed: () async {
83+
result = await ReactionPickerOverlay.show(
84+
context: context,
85+
isSent: false,
86+
);
87+
},
88+
child: const Text('open'),
89+
),
90+
);
91+
},
92+
),
93+
),
94+
);
95+
await tester.tap(find.text('open'));
96+
await tester.pumpAndSettle();
7697

7798
await tester.tap(find.bySemanticsLabel('Add custom emoji reaction'));
7899
await tester.pumpAndSettle();
79100

80101
expect(find.bySemanticsLabel('Add custom emoji reaction'), findsNothing);
102+
expect(result?.openFullPicker, isTrue);
81103
});
82104

83105
testWidgets('omits picker row when showPicker is false', (tester) async {

0 commit comments

Comments
 (0)