Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
941e1c0e69 WIP: New Emoji Picker 2026-08-01 00:58:57 +02:00
49b94a2e34 Merge remote-tracking branch 'origin/main' into custom_emoji_picker_stickers_and_more 2026-07-27 20:17:15 +02:00
0813597a53 Small refactor of emoji controller and widget
Isolated deserialization of the large emoji json to not keep main thread busy to long
Refactored textController logic in EmojiPickerButton to make popping mid buid impossible. The bug from the comment should be impossible now
(The bug could be reproduce with future.delayed and microtask but)
2026-07-25 19:04:56 +02:00
7 changed files with 546 additions and 125 deletions

2
gomuks

@ -1 +1 @@
Subproject commit 1f11743884a64765b1444b250377a09ebcaba93c
Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b

View file

@ -1,16 +1,47 @@
import "dart:convert";
import "package:emoji_text_field/models/emoji_category.dart";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/models/emoji.dart";
import "package:nexus/models/gemoji.dart";
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
class EmoteSection {
final String category;
final String label;
final IconData icon;
final List<EmoteItem> items;
final bool isRecent;
class EmojiController extends AsyncNotifier<EmojiTuple> {
const EmoteSection({
required this.category,
required this.label,
required this.icon,
required this.items,
this.isRecent = false,
});
}
class EmoteItem {
final String id;
final String display;
final List<String> keywords;
final int useCounter;
final Image? customImage;
const EmoteItem({
required this.id,
required this.display,
required this.keywords,
required this.useCounter,
this.customImage,
});
}
class EmojiController extends AsyncNotifier<IList<EmoteSection>> {
@override
Future<EmojiTuple> build() async {
Future<IList<EmoteSection>> build() async {
final response = await get(
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
);
@ -19,66 +50,72 @@ class EmojiController extends AsyncNotifier<EmojiTuple> {
throw Exception("Failed to load emoji data");
}
final data = json.decode(response.body);
final entries = (data as List)
.cast<Map<String, dynamic>>()
.map(Emoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
),
);
final keywordMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.add(
entry.emoji,
.new([...entry.tags, ...entry.aliases, entry.description]),
),
);
final customCategories = IMap.fromEntries(
categoryMap.entries.map(
(entry) => MapEntry(
entry.key,
EmojiCategory(
name: entry.key,
icon: switch (entry.key) {
"Smileys & Emotion" => Icons.emoji_emotions,
"People & Body" => Icons.emoji_people,
"Animals & Nature" => Icons.emoji_nature,
"Food & Drink" => Icons.emoji_food_beverage,
"Travel & Places" => Icons.travel_explore,
"Activities" => Icons.sports_soccer,
"Objects" => Icons.emoji_objects,
"Symbols" => Icons.emoji_symbols,
"Flags" => Icons.emoji_flags,
_ => Icons.category,
},
emojis: entry.value.toList(growable: false),
),
),
),
);
final customKeywords = IMap(
.fromEntries(
keywordMap.entries.map(
(e) => .new(e.key, e.value.toList(growable: false)),
),
),
);
return (customCategories, customKeywords);
return compute(parseEmojiJson, response.body);
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
static IList<EmoteSection> parseEmojiJson(String body) {
final data = json.decode(body) as List;
final allEntries = data
.cast<Map<String, dynamic>>()
.map(Gemoji.fromJson)
.toIList();
final groupedByCategory = groupBy(allEntries, (g) => g.category);
final result = groupedByCategory.entries.fold<IList<EmoteSection>>(
.new(),
(resultList, entry) => resultList.add(
EmoteSection(
category: entry.key,
label: entry.key,
icon: getIconForCategory(entry.key),
items: entry.value
.map(
(gemoji) => EmoteItem(
id: gemoji.emoji,
display: gemoji.emoji,
keywords: [
...gemoji.tags,
...gemoji.aliases,
...gemoji.description.split(" "),
],
useCounter: 0,
),
)
.toList(),
isRecent: false,
),
),
);
return result;
}
static final provider =
AsyncNotifierProvider<EmojiController, IList<EmoteSection>>(
EmojiController.new,
);
static IconData getIconForCategory(String category) {
switch (category.toLowerCase()) {
case "smileys & emotion":
return Icons.sentiment_very_satisfied;
case "people & body":
return Icons.face;
case "food & drink":
return Icons.restaurant;
case "travel & places":
return Icons.flag;
case "activities":
return Icons.sports_soccer;
case "objects":
return Icons.lightbulb;
case "symbols":
return Icons.circle;
case "flags":
return Icons.flag;
default:
return Icons.emoji_emotions_outlined;
}
}
}

View file

@ -1,17 +1,17 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "emoji.freezed.dart";
part "emoji.g.dart";
part "gemoji.freezed.dart";
part "gemoji.g.dart";
@freezed
abstract class Emoji with _$Emoji {
const factory Emoji({
abstract class Gemoji with _$Gemoji {
const factory Gemoji({
required String emoji,
required String category,
required IList<String> aliases,
required String description,
required IList<String> tags,
}) = _Emoji;
}) = _Gemoji;
factory Emoji.fromJson(Map<String, Object?> json) => _$EmojiFromJson(json);
factory Gemoji.fromJson(Map<String, Object?> json) => _$GemojiFromJson(json);
}

View file

@ -14,8 +14,8 @@ import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/mention_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/emote_picker.dart";
class Composer extends HookConsumerWidget {
final String roomId;
@ -123,10 +123,35 @@ class Composer extends HookConsumerWidget {
),
)
? [
EmojiPickerButton(
context: context,
onSelection: (_) => node?.requestFocus(),
controller: controller.value,
IconButton(
icon: Icon(Icons.emoji_emotions),
tooltip: "Insert emoji",
onPressed: () {
showEmotePickerBottomSheet(
context: context,
roomId: roomId,
onSelect: (emojiId) {
final textController = controller.value;
final selection = textController.selection;
final newText = textController.text
.replaceRange(
selection.start,
selection.end,
emojiId,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset:
selection.start + emojiId.length,
),
);
node?.requestFocus();
},
);
},
),
PopupMenuButton(
tooltip: "Add media",

View file

@ -1,52 +0,0 @@
import "package:emoji_text_field/emoji_text_field.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
class EmojiPickerButton extends HookConsumerWidget {
final TextEditingController? controller;
final void Function(String emoji)? onSelection;
final VoidCallback? onPressed;
final BuildContext context;
const EmojiPickerButton({
this.controller,
this.onPressed,
this.onSelection,
required this.context,
super.key,
});
@override
Widget build(_, WidgetRef ref) => IconButton(
onPressed: () async {
onPressed?.call();
final controller = this.controller ?? .new();
final emojis = await ref.watch(EmojiController.provider.future);
if (context.mounted) {
showModalBottomSheet(
context: context,
builder: (context) => EmojiKeyboardView(
config: .new(
showRecentTab: false,
customCategories: emojis.$1.unlock,
customKeywords: emojis.$2.unlock,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
height: 600,
),
textController: controller
..addListener(() {
// Without this, there will sometimes be a debugLocked is not true error sometimes
// It might be preferable to use a microtask instead of a `Future.delayed`.
Future.delayed(.zero, () {
if (context.mounted) Navigator.of(context).pop();
});
onSelection?.call(controller.text);
}),
),
);
}
},
icon: Icon(Icons.emoji_emotions),
);
}

View file

@ -0,0 +1,399 @@
import "dart:math" as math;
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter/rendering.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
class EmotePicker extends HookConsumerWidget {
final String? roomId;
final void Function(String emoteId) onSelect;
final VoidCallback? onClose;
const EmotePicker({
super.key,
this.roomId,
this.onClose,
required this.onSelect,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
minHeight: math.min(MediaQuery.of(context).size.height * 0.6, 400),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
Divider(height: 1),
// Content
Expanded(
child: ListView(
children: [
EmoteCategoryList(
roomId: roomId,
onSelect: onSelect,
fetchSections: (rid, query) =>
fetchEmojiSections(ref, rid, query),
placeholderText: "emojis & emotes",
),
],
),
),
],
),
),
);
}
}
class EmoteCategoryList extends HookWidget {
final String? roomId;
final void Function(String) onSelect;
final Future<IList<EmoteSection>> Function(String? roomId, String query)
fetchSections;
final String placeholderText;
const EmoteCategoryList({
super.key,
required this.roomId,
required this.onSelect,
required this.fetchSections,
required this.placeholderText,
});
@override
Widget build(BuildContext context) {
final searchController = useTextEditingController(text: "");
final searchFocus = useFocusNode();
final searchText = useState("");
useEffect(() {
void listener() {
searchText.value = searchController.text;
}
searchController.addListener(listener);
return () => searchController.removeListener(listener);
}, [searchController]);
final isSearching = searchText.value.isNotEmpty;
final sectionsFuture = useMemoized(
() => fetchSections(roomId, searchText.value),
[searchText.value],
);
final sectionsSnapshot = useFuture(sectionsFuture);
final sections = sectionsSnapshot.data ?? [].toIList();
final scrollController = useScrollController();
final sectionKeysMap = useState(<String, GlobalKey>{});
final activeCategoryId = useState<String?>(null);
useEffect(() {
if (sections.isEmpty) return null;
bool active = true;
void listener() {
if (!active || !scrollController.hasClients) return;
final scrollPixels = scrollController.offset;
String? current;
for (final section in sections) {
final key = sectionKeysMap.value.putIfAbsent(
section.category,
() => GlobalKey(),
);
if (key.currentContext?.findRenderObject() == null) continue;
final ro = key.currentContext!.findRenderObject()!;
if (!ro.attached) continue;
try {
final vp = RenderAbstractViewport.of(ro);
final reveal = vp.getOffsetToReveal(ro, 0.0).offset;
if (reveal <= scrollPixels + 80) {
current = section.category;
} else {
break;
}
} catch (_) {
continue;
}
}
if (current != null && current != activeCategoryId.value) {
activeCategoryId.value = current;
}
}
scrollController.addListener(listener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (active) listener();
});
return () {
active = false;
scrollController.removeListener(listener);
};
}, [sections, scrollController]);
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: searchController,
focusNode: searchFocus,
decoration: InputDecoration(
hintText: "Search $placeholderText...",
prefixIcon: Icon(Icons.search),
suffixIcon: isSearching
? IconButton(
icon: Icon(Icons.clear),
onPressed: () {
searchController.clear();
searchFocus.requestFocus();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
Expanded(
child: Row(
children: [
if (!isSearching) ...[
SizedBox(
width: 100,
child: ListView.builder(
itemCount: sections.length,
itemBuilder: (context, index) {
final section = sections[index];
final isActive =
activeCategoryId.value == section.category;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
activeCategoryId.value = section.category;
final key = sectionKeysMap.value[section.category];
if (key?.currentContext != null) {
Scrollable.ensureVisible(
key!.currentContext!,
alignment: 0.0,
duration: const Duration(milliseconds: 250),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
width: 2,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
),
color: isActive
? Theme.of(context).colorScheme.primary
.withValues(alpha: 0.08)
: Colors.transparent,
),
child: Text(
section.isRecent ? "Recent" : section.label,
style: TextStyle(
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : null,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
},
),
),
const VerticalDivider(width: 1),
],
Expanded(
child:
sectionsSnapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: sections.isEmpty
? Center(child: Text("No $placeholderText found"))
: CustomScrollView(
controller: scrollController,
slivers: [
for (final section in sections) ...[
SliverToBoxAdapter(
key: sectionKeysMap.value.putIfAbsent(
section.category,
() => GlobalKey(),
),
child: Container(
padding: const EdgeInsets.only(
left: 12,
top: 12,
bottom: 8,
),
child: Text(
section.isRecent
? "Recently Used"
: section.label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 44,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final item = section.items[index];
return GestureDetector(
onTap: () => onSelect(item.id),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
6,
),
),
child: Text(
item.display,
style: const TextStyle(fontSize: 24),
),
),
);
}, childCount: section.items.length),
),
),
],
const SliverPadding(
padding: EdgeInsets.only(bottom: 24),
),
],
),
),
],
),
),
],
);
}
}
Future<IList<EmoteSection>> fetchEmojiSections(
WidgetRef ref,
String? roomId,
String query,
) async {
final emotes = await ref.watch(EmojiController.provider.future);
final q = query.toLowerCase();
if (q.isEmpty) {
return emotes;
}
return emotes
.map((cat) {
final filtered = cat.items
.where(
(e) =>
e.keywords.any((k) => k.contains(q)) || e.id.contains(query),
)
.toList();
return filtered.isNotEmpty
? EmoteSection(
category: cat.category,
label: cat.label,
icon: cat.icon,
items: filtered,
isRecent: cat.isRecent,
)
: null;
})
.whereType<EmoteSection>()
.toIList();
}
Future<void> showEmotePickerBottomSheet({
required BuildContext context,
String? roomId,
required void Function(String) onSelect,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useSafeArea: true,
enableDrag: true,
isDismissible: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
maxWidth: 600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => EmotePicker(roomId: roomId, onSelect: onSelect),
);
}

View file

@ -16,7 +16,7 @@ import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/widgets/emote_picker.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
@ -239,11 +239,23 @@ class RoomChat extends HookConsumerWidget {
icon: Text(emoji),
),
),
EmojiPickerButton(
context: context,
onPressed: Navigator.of(context).pop,
onSelection: (emoji) =>
notifier.sendReaction(emoji, event).onError(showError),
GestureDetector(
onTap: () {
Navigator.of(context).pop(); // Close popup menu
showEmotePickerBottomSheet(
context: context,
roomId: roomId,
onSelect: (emojiId) async {
await notifier
.sendReaction(emojiId, event)
.onError(showError);
},
);
},
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.emoji_emotions),
),
),
],
),