allow sending emoji/stickers from emote packs

This commit is contained in:
Henry Hiles 2026-10-23 18:16:45 -04:00
commit 3754bc2feb
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 701 additions and 74 deletions

View file

@ -17,7 +17,11 @@ import "package:nexus/controllers/image_picker.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:image_picker/image_picker.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/widgets/sticker_picker.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
import "package:nexus/widgets/composer/paste_files_action.dart";
@ -38,6 +42,8 @@ class const Composer(
required IList<Tag> tags,
})
onSend,
required final Future<void> Function(String shortcode, PackImage image)
onSendSticker,
final FocusNode? node,
super.key,
}) extends HookConsumerWidget {
@ -48,6 +54,17 @@ class const Composer(
final triggerCharacter = useState("");
final shouldMention = useState(true);
final query = useState("");
final canSendStickers =
ref.watch(
PowerLevelController.provider(
.new(eventType: .sticker, roomId: roomId),
),
) &&
(ref
.watch(ImagePacksController.provider(roomId))
.value
?.any((pack) => pack.stickers.isNotEmpty) ??
false);
final relatedEvent = this.relatedEvent == null
? null
: ref.watch(
@ -253,9 +270,27 @@ class const Composer(
IconButton(
onPressed: () => context.showAdaptiveSheet(
(context) => EmojiPicker(
onSelection: (value) {
roomId: roomId,
onSelection: (value, {shortcode}) {
Navigator.of(context).pop();
controller.text += value;
if (shortcode == null) {
controller.text += value;
} else {
final text = "${controller.text}:";
controller.value = .new(
text: text,
selection: .collapsed(
offset: text.length,
),
);
controller.addTag(
id: ImagePack.emojiMarkdown(
shortcode,
value,
),
name: shortcode,
);
}
node?.requestFocus();
},
),
@ -308,6 +343,22 @@ class const Composer(
leading: Icon(Icons.attachment),
),
),
if (canSendStickers)
PopupMenuItem(
onTap: () => context.showAdaptiveSheet(
(context) => StickerPicker(
roomId: roomId,
onSelection: (shortcode, image) {
Navigator.of(context).pop();
onSendSticker(shortcode, image);
},
),
),
child: ListTile(
title: Text("Sticker"),
leading: Icon(Icons.sticky_note_2),
),
),
],
icon: Icon(Icons.add),
),
@ -315,6 +366,9 @@ class const Composer(
Expanded(
child: FlutterTagger(
triggerStrategy: .eager,
// Characters that continue a search, for
// e.g. :+1, :custom_emoji or @user.name
searchRegex: .new(r"^[\w.+-]*$"),
overlay: TaggerOverlay(
query.value,
roomId: roomId,

View file

@ -1,8 +1,9 @@
import "package:collection/collection.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/all_emoji.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/image_pack.dart";
class const EmojiOverlay(
final String query, {
@ -13,7 +14,7 @@ class const EmojiOverlay(
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(EmojiController.provider)
.watch(AllEmojiController.provider(roomId))
.betterWhen(
data: (emojis) => ListView(
children: emojis
@ -34,8 +35,11 @@ class const EmojiOverlay(
title: Text(emoji.aliases.first),
subtitle: Text(emoji.description),
onTap: () => addTag(
id: Uri.tryParse(emoji.value)?.scheme == "mxc"
? "" // TODO: Handle custom emotes
id: emoji.value.startsWith("mxc://")
? ImagePack.emojiMarkdown(
emoji.aliases.first,
emoji.value,
)
: emoji.value,
name: emoji.aliases.first,
),

View file

@ -1,15 +1,23 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart" as upstream;
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/all_emoji.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/image_pack.dart";
class const EmojiPicker({
required final FutureOr<void> Function(String value) onSelection,
required final String roomId,
required final FutureOr<void> Function(String value, {String? shortcode})
onSelection,
final bool allowFreeText = false,
super.key,
}) extends ConsumerWidget {
@ -18,39 +26,51 @@ class const EmojiPicker({
final recentEmoji = ref.watch(RecentEmojiController.provider);
return ref
.watch(upstream.EmojiController.provider)
.watch(AllEmojiController.provider(roomId))
.betterWhen(
data: (categories) => upstream.EmojiPicker(
onSelection: (emoji, isFreeText) async {
await onSelection(emoji);
data: (categories) {
final allEmoji = categories.expand((category) => category.emojis);
final customCategories =
ref
.watch(ImagePacksController.provider(roomId))
.value
?.map((pack) => pack.emojiCategory)
.nonNulls
.toIList() ??
const .empty();
return upstream.EmojiPicker(
onSelection: (emoji) async {
await onSelection(
emoji.value,
shortcode: emoji.data is PackImage
? emoji.aliases.first
: null,
);
if (!isFreeText) {
await ref
.read(RecentEmojiController.provider.notifier)
.add(emoji)
.add(emoji.value)
.onError(showError);
}
},
allowFreeText: allowFreeText,
prependCategories: .new([
.new(
icon: Icon(Icons.history),
name: "Recent",
emojis: .new(
recentEmoji
.map(
(recent) => categories
.map((element) => element.emojis)
.flattened
.firstWhereOrNull(
(element) => element.value == recent.emoji,
),
)
.nonNulls,
},
onFreeText: allowFreeText ? (text) => onSelection(text) : null,
prependCategories: IList<upstream.EmojiCategory>([
.new(
icon: Icon(Icons.history),
name: "Recent",
emojis: .new(
recentEmoji
.map(
(recent) => allEmoji.firstWhereOrNull(
(element) => element.value == recent.emoji,
),
)
.nonNulls,
),
),
),
]),
),
]).addAll(customCategories),
);
},
);
}
}

View file

@ -0,0 +1,16 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/mxc_image.dart";
/// A custom emoji or sticker from an image pack, scaled to fit its parent.
class const MxcEmoji(final Uri url, {final String? label, super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => Image(
image: MxcImage(ref, .new(mxc: url)),
semanticLabel: label,
fit: .contain,
errorBuilder: (context, _, _) =>
Icon(Icons.broken_image, color: Theme.of(context).colorScheme.error),
);
}

View file

@ -323,6 +323,10 @@ final class const RoomChat({
relatedEvent.value,
)
.onError(showError),
onSendSticker: (shortcode, image) =>
notifier
.sendSticker(shortcode, image)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {

View file

@ -0,0 +1,47 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
import "package:nexus/models/content/image_pack.dart";
/// Stickers from the image packs available in [roomId], grouped by pack.
class const StickerPicker({
required final String roomId,
required final FutureOr<void> Function(String shortcode, PackImage image)
onSelection,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(ImagePacksController.provider(roomId))
.betterWhen(
data: (packs) {
final categories = packs
.map((pack) => pack.stickerCategory)
.nonNulls
.toIList();
return categories.isEmpty
? Padding(
padding: .all(16),
child: Center(child: Text("No sticker packs available")),
)
: EmojiPicker(
prependCategories: categories,
includeDefaultEmoji: false,
itemSize: 96,
searchHint: "Search stickers",
onSelection: (sticker) {
if (sticker.data case final PackImage image) {
return onSelection(sticker.aliases.first, image);
}
},
);
},
);
}