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

WIP: New Emoji Picker

This commit is contained in:
istalri 2026-08-01 00:58:57 +02:00
commit 941e1c0e69
7 changed files with 543 additions and 140 deletions

2
gomuks

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

View file

@ -1,17 +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"),
);
@ -20,69 +50,72 @@ class EmojiController extends AsyncNotifier<EmojiTuple> {
throw Exception("Failed to load emoji data");
}
return compute(_parseEmojiJson, response.body);
return compute(parseEmojiJson, response.body);
}
static EmojiTuple _parseEmojiJson(String body) {
static IList<EmoteSection> parseEmojiJson(String body) {
final data = json.decode(body) as List;
final entries = data
final allEntries = data
.cast<Map<String, dynamic>>()
.map(Emoji.fromJson)
.map(Gemoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
final groupedByCategory = groupBy(allEntries, (g) => g.category);
final result = groupedByCategory.entries.fold<IList<EmoteSection>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
(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,
),
);
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)),
)
.toList(),
isRecent: false,
),
),
);
return (customCategories, customKeywords);
return result;
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
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(
IconButton(
icon: Icon(Icons.emoji_emotions),
tooltip: "Insert emoji",
onPressed: () {
showEmotePickerBottomSheet(
context: context,
onSelection: (_) => node?.requestFocus(),
controller: controller.value,
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,66 +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 tempController = controller ?? TextEditingController();
final shouldDispose = controller == null;
var hasPopped = false;
void handler() {
if (tempController.text.isEmpty || hasPopped) return;
hasPopped = true;
onSelection?.call(tempController.text);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).maybePop();
});
}
final emojis = await ref.watch(EmojiController.provider.future);
if (!context.mounted) {
if (shouldDispose) tempController.dispose();
return;
}
await showModalBottomSheet(
context: context,
builder: (sheetContext) => EmojiKeyboardView(
config: .new(
showRecentTab: false,
customCategories: emojis.$1.unlock,
customKeywords: emojis.$2.unlock,
backgroundColor: Theme.of(
sheetContext,
).colorScheme.surfaceContainer,
height: 600,
),
textController: tempController..addListener(handler),
),
);
tempController.removeListener(handler);
if (shouldDispose) tempController.dispose();
},
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(
GestureDetector(
onTap: () {
Navigator.of(context).pop(); // Close popup menu
showEmotePickerBottomSheet(
context: context,
onPressed: Navigator.of(context).pop,
onSelection: (emoji) =>
notifier.sendReaction(emoji, event).onError(showError),
roomId: roomId,
onSelect: (emojiId) async {
await notifier
.sendReaction(emojiId, event)
.onError(showError);
},
);
},
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.emoji_emotions),
),
),
],
),