Skip to content

Commit 10b6674

Browse files
wesbillmanBrainPinky
authored
Mobile custom emoji + settings redesign (#906)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
1 parent 732e23d commit 10b6674

23 files changed

Lines changed: 1268 additions & 226 deletions

mobile/lib/features/activity/activity_page.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../shared/widgets/frosted_app_bar.dart';
99
import '../../shared/widgets/frosted_scaffold.dart';
1010
import '../channels/channel.dart';
1111
import '../channels/channel_detail_page.dart';
12+
import '../channels/message_content.dart';
1213
import '../channels/channels_provider.dart';
1314
import '../channels/small_avatar.dart';
1415
import '../profile/user_cache_provider.dart';
@@ -229,11 +230,11 @@ class _FeedItemTile extends ConsumerWidget {
229230
),
230231
const SizedBox(height: Grid.half),
231232
// Content preview (max 2 lines)
232-
Text(
233-
item.displayContent,
233+
MessageContent(
234+
content: item.displayContent,
235+
tags: item.tags,
234236
maxLines: 2,
235-
overflow: TextOverflow.ellipsis,
236-
style: context.textTheme.bodySmall?.copyWith(
237+
baseStyle: context.textTheme.bodySmall?.copyWith(
237238
color: context.colors.onSurfaceVariant,
238239
),
239240
),

mobile/lib/features/channels/channel_management_provider.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
66
import '../../shared/auth/auth.dart';
77
import '../../shared/relay/relay.dart';
88
import '../profile/profile_provider.dart';
9+
import '../custom_emoji/custom_emoji.dart';
10+
import '../custom_emoji/custom_emoji_provider.dart';
911
import 'channel.dart';
1012
import 'channels_provider.dart';
1113

@@ -393,11 +395,18 @@ class ChannelActions {
393395
}
394396

395397
Future<void> addReaction(String eventId, String emoji) async {
398+
final shortcode = normalizeShortcode(emoji);
399+
final emojiUrl = reactionEmojiUrl(
400+
emoji,
401+
_ref.read(customEmojiListProvider),
402+
);
396403
await _signedEventRelay.submit(
397404
kind: EventKind.reaction,
398405
content: emoji,
399406
tags: [
400407
['e', eventId],
408+
if (shortcode != null && emojiUrl != null)
409+
['emoji', shortcode, emojiUrl],
401410
],
402411
);
403412
}
@@ -416,13 +425,15 @@ class ChannelActions {
416425
required String channelId,
417426
required String eventId,
418427
required String content,
428+
List<List<String>> mediaTags = const [],
419429
}) async {
420430
await _signedEventRelay.submit(
421431
kind: EventKind.streamMessageEdit,
422432
content: content,
423433
tags: [
424434
['h', channelId],
425435
['e', eventId],
436+
...mediaTags,
426437
],
427438
);
428439
}

mobile/lib/features/channels/compose_bar.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import '../../shared/relay/relay.dart';
99
import '../../shared/theme/theme.dart';
1010
import '../profile/user_cache_provider.dart';
1111
import '../profile/user_profile.dart';
12+
import '../custom_emoji/custom_emoji.dart';
13+
import '../custom_emoji/custom_emoji_provider.dart';
1214
import 'channel.dart';
1315
import 'channel_management_provider.dart';
1416
import 'channels_provider.dart';
@@ -55,6 +57,7 @@ class ComposeBar extends HookConsumerWidget {
5557
final uploadingCount = useState(0);
5658
final hasAttachments = attachments.value.isNotEmpty;
5759
final hasPendingUploads = uploadingCount.value > 0;
60+
final customEmoji = ref.watch(customEmojiListProvider);
5861

5962
final resolvedHint =
6063
hintText ??
@@ -239,6 +242,7 @@ class ComposeBar extends HookConsumerWidget {
239242
final payload = _ComposeDraftPayload.fromDraft(
240243
text: text,
241244
attachments: attachments.value,
245+
customEmoji: customEmoji,
242246
);
243247

244248
isSending.value = true;
@@ -940,13 +944,15 @@ class _ComposeDraftPayload {
940944
factory _ComposeDraftPayload.fromDraft({
941945
required String text,
942946
required List<BlobDescriptor> attachments,
947+
required List<CustomEmoji> customEmoji,
943948
}) {
944949
var content = text;
945950
final mediaTags = <List<String>>[];
946951
for (final attachment in attachments) {
947952
mediaTags.add(attachment.toImetaTag());
948953
content += '\n${attachment.toMarkdownImage()}';
949954
}
955+
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
950956
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
951957
}
952958
}

mobile/lib/features/channels/emoji_picker.dart

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import 'package:flutter/material.dart';
2+
import 'package:hooks_riverpod/hooks_riverpod.dart';
23
import 'package:lucide_icons_flutter/lucide_icons.dart';
34

45
import '../../shared/theme/theme.dart';
6+
import '../custom_emoji/custom_emoji.dart';
7+
import '../custom_emoji/custom_emoji_provider.dart';
8+
import '../custom_emoji/custom_emoji_render.dart';
59

610
/// Opens the full emoji picker as a modal bottom sheet.
711
void showEmojiPicker({
@@ -185,16 +189,16 @@ const emojiCategories = <({String label, IconData icon, List<String> emoji})>[
185189
),
186190
];
187191

188-
class EmojiPickerSheet extends StatefulWidget {
192+
class EmojiPickerSheet extends ConsumerStatefulWidget {
189193
final void Function(String emoji) onSelect;
190194

191195
const EmojiPickerSheet({super.key, required this.onSelect});
192196

193197
@override
194-
State<EmojiPickerSheet> createState() => _EmojiPickerSheetState();
198+
ConsumerState<EmojiPickerSheet> createState() => _EmojiPickerSheetState();
195199
}
196200

197-
class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
201+
class _EmojiPickerSheetState extends ConsumerState<EmojiPickerSheet> {
198202
/// -1 = "All", 0..N = specific category.
199203
int _selectedCategory = -1;
200204

@@ -210,9 +214,14 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
210214
@override
211215
Widget build(BuildContext context) {
212216
final colors = Theme.of(context).colorScheme;
217+
final customEmoji = ref.watch(customEmojiListProvider);
218+
final isCustomCategory =
219+
customEmoji.isNotEmpty && _selectedCategory == emojiCategories.length;
213220
final emoji = _selectedCategory < 0
214221
? _allEmoji
215-
: emojiCategories[_selectedCategory].emoji;
222+
: _selectedCategory < emojiCategories.length
223+
? emojiCategories[_selectedCategory].emoji
224+
: const <String>[];
216225

217226
return SizedBox(
218227
height: 340,
@@ -235,38 +244,88 @@ class _EmojiPickerSheetState extends State<EmojiPickerSheet> {
235244
selected: _selectedCategory == i,
236245
onTap: () => setState(() => _selectedCategory = i),
237246
),
247+
if (customEmoji.isNotEmpty)
248+
CategoryIcon(
249+
icon: LucideIcons.sparkles,
250+
selected: isCustomCategory,
251+
onTap: () => setState(
252+
() => _selectedCategory = emojiCategories.length,
253+
),
254+
),
238255
],
239256
),
240257
),
241258
Divider(height: 1, color: colors.outlineVariant),
242259
const SizedBox(height: Grid.xxs),
243260
// Emoji grid.
244261
Expanded(
245-
child: GridView.builder(
246-
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
247-
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
248-
crossAxisCount: 8,
249-
mainAxisSpacing: Grid.half,
250-
crossAxisSpacing: Grid.half,
251-
),
252-
itemCount: emoji.length,
253-
itemBuilder: (context, index) {
254-
final e = emoji[index];
255-
return GestureDetector(
256-
onTap: () => widget.onSelect(e),
257-
child: Center(
258-
child: Text(e, style: const TextStyle(fontSize: 28)),
262+
child: isCustomCategory
263+
? _CustomEmojiGrid(
264+
emoji: customEmoji,
265+
onSelect: widget.onSelect,
266+
)
267+
: GridView.builder(
268+
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
269+
gridDelegate:
270+
const SliverGridDelegateWithFixedCrossAxisCount(
271+
crossAxisCount: 8,
272+
mainAxisSpacing: Grid.half,
273+
crossAxisSpacing: Grid.half,
274+
),
275+
itemCount: emoji.length,
276+
itemBuilder: (context, index) {
277+
final e = emoji[index];
278+
return GestureDetector(
279+
onTap: () => widget.onSelect(e),
280+
child: Center(
281+
child: Text(e, style: const TextStyle(fontSize: 28)),
282+
),
283+
);
284+
},
259285
),
260-
);
261-
},
262-
),
263286
),
264287
],
265288
),
266289
);
267290
}
268291
}
269292

293+
class _CustomEmojiGrid extends StatelessWidget {
294+
final List<CustomEmoji> emoji;
295+
final void Function(String emoji) onSelect;
296+
297+
const _CustomEmojiGrid({required this.emoji, required this.onSelect});
298+
299+
@override
300+
Widget build(BuildContext context) {
301+
return GridView.builder(
302+
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
303+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
304+
crossAxisCount: 6,
305+
mainAxisSpacing: Grid.half,
306+
crossAxisSpacing: Grid.half,
307+
),
308+
itemCount: emoji.length,
309+
itemBuilder: (context, index) {
310+
final entry = emoji[index];
311+
return GestureDetector(
312+
onTap: () => onSelect(':${entry.shortcode}:'),
313+
child: Tooltip(
314+
message: ':${entry.shortcode}:',
315+
child: Center(
316+
child: CustomEmojiImage(
317+
shortcode: entry.shortcode,
318+
url: entry.url,
319+
size: 32,
320+
),
321+
),
322+
),
323+
);
324+
},
325+
);
326+
}
327+
}
328+
270329
class CategoryIcon extends StatelessWidget {
271330
final IconData icon;
272331
final bool selected;

mobile/lib/features/channels/message_actions.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
44
import 'package:lucide_icons_flutter/lucide_icons.dart';
55

66
import '../../shared/theme/theme.dart';
7+
import '../custom_emoji/custom_emoji.dart';
8+
import '../custom_emoji/custom_emoji_provider.dart';
79
import 'channel_management_provider.dart';
810
import 'emoji_picker.dart';
911
import 'thread_detail_page.dart';
@@ -220,6 +222,10 @@ void _showEditSheet({
220222
channelId: channelId,
221223
eventId: message.id,
222224
content: text,
225+
mediaTags: buildCustomEmojiTags(
226+
text,
227+
ref.read(customEmojiListProvider),
228+
),
223229
);
224230
Navigator.of(sheetContext).pop();
225231
},

mobile/lib/features/channels/message_content.dart

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:math' as math;
22

33
import 'package:flutter/material.dart';
44
import 'package:flutter_hooks/flutter_hooks.dart';
5+
import 'package:hooks_riverpod/hooks_riverpod.dart';
56
import 'package:gpt_markdown/gpt_markdown.dart';
67
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
78
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -10,6 +11,9 @@ import 'package:url_launcher/url_launcher.dart';
1011
import '../../shared/clipboard_utils.dart';
1112
import '../../shared/syntax_highlight.dart';
1213
import '../../shared/theme/theme.dart';
14+
import '../custom_emoji/custom_emoji.dart';
15+
import '../custom_emoji/custom_emoji_provider.dart';
16+
import '../custom_emoji/custom_emoji_render.dart';
1317
import 'media_viewer_page.dart';
1418
import 'message_media.dart';
1519

@@ -18,7 +22,7 @@ const _messageMediaMaxImageHeight = 240.0;
1822

1923
/// Renders message content with markdown formatting, @mentions, #channel links,
2024
/// and media-aware markdown images/videos.
21-
class MessageContent extends HookWidget {
25+
class MessageContent extends HookConsumerWidget {
2226
final String content;
2327

2428
/// Display names for mentioned pubkeys, extracted from event p-tags.
@@ -37,6 +41,8 @@ class MessageContent extends HookWidget {
3741

3842
final TextStyle? baseStyle;
3943

44+
final int? maxLines;
45+
4046
const MessageContent({
4147
super.key,
4248
required this.content,
@@ -45,14 +51,19 @@ class MessageContent extends HookWidget {
4551
this.tags = const [],
4652
this.onChannelTap,
4753
this.baseStyle,
54+
this.maxLines,
4855
});
4956

5057
@override
51-
Widget build(BuildContext context) {
58+
Widget build(BuildContext context, WidgetRef ref) {
5259
final style =
5360
baseStyle ??
5461
context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface);
5562
final imetaByUrl = parseImetaTags(tags);
63+
final customEmoji = _mergeCustomEmoji(
64+
customEmojiFromTags(tags),
65+
ref.watch(customEmojiListProvider),
66+
);
5667

5768
final finalContent = useMemoized(() {
5869
// Convert autolinks and bare URLs to standard markdown links,
@@ -132,8 +143,10 @@ class MessageContent extends HookWidget {
132143
_buildLink(context, linkText, url, linkStyle, style),
133144
imageBuilder: (context, imageUrl) =>
134145
_buildMedia(context, imageUrl, imetaByUrl[imageUrl]),
146+
maxLines: maxLines,
135147
inlineComponents: [
136148
_MentionMd(mentionNames: mentionNames),
149+
CustomEmojiMd(customEmoji),
137150
_ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap),
138151
...MarkdownComponent.inlineComponents,
139152
],
@@ -192,6 +205,20 @@ class MessageContent extends HookWidget {
192205
}
193206
}
194207

208+
List<CustomEmoji> _mergeCustomEmoji(
209+
List<CustomEmoji> eventEmoji,
210+
List<CustomEmoji> paletteEmoji,
211+
) {
212+
if (eventEmoji.isEmpty) return paletteEmoji;
213+
if (paletteEmoji.isEmpty) return eventEmoji;
214+
final seen = <String>{};
215+
final merged = <CustomEmoji>[];
216+
for (final emoji in [...eventEmoji, ...paletteEmoji]) {
217+
if (seen.add(emoji.shortcode)) merged.add(emoji);
218+
}
219+
return merged;
220+
}
221+
195222
class _MessageImagePreview extends StatefulWidget {
196223
final String url;
197224
final ImetaEntry? imeta;

0 commit comments

Comments
 (0)