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
23 changed files with 701 additions and 74 deletions

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
class AllEmojiController(final String roomId)
extends AsyncNotifier<IList<EmojiCategory>> {
@override
Future<IList<EmojiCategory>> build() async {
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
final unicode = await ref.watch(EmojiController.provider.future);
return packs
.map((pack) => pack.emojiCategory)
.nonNulls
.toIList()
.addAll(unicode);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<AllEmojiController, IList<EmojiCategory>, String>(
AllEmojiController.new,
);
}

View file

@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart";
@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier<int> {
...json,
"content": {
...content,
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
if (request.relatesTo != null)
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
},
}),
);
@ -265,6 +267,15 @@ class ClientController extends AsyncNotifier<int> {
);
}
Future<IList<Event>> getSpecificRoomState(
GetSpecificRoomStateRequest request,
) async => .new(
((await _sendCommand("get_specific_room_state", request.toJson()))
as List? ??
[])
.map((event) => .fromJson(event)),
);
Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request,
) async {

View file

@ -0,0 +1,112 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
typedef ImagePackSource = ({String roomId, String? stateKey});
class ImagePackSourcesController(final String roomId)
extends AsyncNotifier<IList<ImagePackSource>> {
@override
Future<IList<ImagePackSource>> build() async {
final enabledPacks = ref.watch(
AccountDataController.provider.select(
(data) =>
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
) ??
const .empty(),
),
);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final directParents = ref.watch(
RoomsController.provider.select(
(rooms) =>
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
const .empty(),
),
);
Iterable<String> parentsOf(String childId) => spaceEdges.entries
.where((entry) => entry.value.any((edge) => edge.childId == childId))
.map((entry) => entry.key)
.followedBy(childId == roomId ? directParents : const []);
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
parentsOf(childId).fold(
spaces,
(spaces, parentId) => spaces.contains(parentId)
? spaces
: withAncestors(spaces.add(parentId), parentId),
);
final rooms = ref.read(RoomsController.provider);
final spaces = withAncestors(
const .empty(),
roomId,
).remove(roomId).where(rooms.containsKey).toIList();
final client = ref.read(ClientController.provider.notifier);
final roomsNotifier = ref.read(RoomsController.provider.notifier);
await Future.wait(
spaces
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
.map(
(spaceId) async => roomsNotifier.addState(
spaceId,
await client.getRoomState(.new(roomId: spaceId)),
),
),
);
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
final missingKeys = enabledPacks.entries
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
.expand(
(entry) => entry.value
.where(
(stateKey) => packTypes.every(
(type) =>
rooms[entry.key]?.state[type.type]?[stateKey] == null,
),
)
.expand(
(stateKey) => packTypes.map(
(type) => RoomStateKey(
roomId: entry.key,
type: type.type,
stateKey: stateKey,
),
),
),
)
.toIList();
if (missingKeys.isNotEmpty) {
roomsNotifier.addSpecificState(
await client.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return .new([
(roomId: roomId, stateKey: null),
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
...enabledPacks.entries.expand(
(entry) => entry.value.map(
(stateKey) => (roomId: entry.key, stateKey: stateKey),
),
),
]);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
ImagePackSourcesController.new,
);
}

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/image_pack_sources.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/image_pack.dart";
class ImagePacksController(final String roomId)
extends AsyncNotifier<IList<ImagePack>> {
@override
Future<IList<ImagePack>> build() async {
final sources = await ref.watch(
ImagePackSourcesController.provider(roomId).future,
);
final packs = ref.watch(
RoomsController.provider.select(
(rooms) => sources.expand((source) {
final room = rooms[source.roomId];
if (room == null) return const <ImagePack>[];
final newPacks = room.state[EventType.imagePack.type];
final legacyPacks = room.state[EventType.legacyImagePack.type];
final roomName = room.metadata?.name ?? source.roomId;
return switch (source.stateKey) {
final stateKey? => ISet([stateKey]),
null => ISet(
newPacks?.keys ?? const <String>[],
).addAll(legacyPacks?.keys ?? const []),
}
.map(
(stateKey) =>
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
final rowId? => switch (room.events[rowId]) {
Event(
redactedBy: null,
content: final ImagePackContent content,
) =>
ImagePack.fromContent(
content,
id: "${source.roomId}/$stateKey",
fallbackName: stateKey.isEmpty
? roomName
: "$roomName - $stateKey",
),
_ => null,
},
null => null,
},
)
.nonNulls;
}).toIList(),
),
);
return .new(
packs
.whereNoDuplicates(by: (pack) => pack.id)
.where(
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
),
);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePacksController, IList<ImagePack>, String>(
ImagePacksController.new,
);
}

View file

@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
),
);
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
return .new(
recents.where(
(recent) =>
// Is unicode or custom emote
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
),
);
}
Future<void> add(String emoji) => ref

View file

@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
@ -315,14 +317,24 @@ class RoomChatController(final ChatParams info)
}
}
Future<void> sendReaction(String reaction, Event event) async {
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
/// with their [shortcode].
Future<void> sendReaction(
String reaction,
Event event, {
String? shortcode,
}) async {
final client = ref.read(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: info.roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
// Wrapped in colons, like other clients send it
content: ReactionContent(
key: reaction,
shortcode: shortcode == null ? null : ":$shortcode:",
),
synchronous: true,
disableEncryption: true,
relatesTo: event.eventId,
@ -331,6 +343,33 @@ class RoomChatController(final ChatParams info)
);
}
Future<void> sendSticker(String shortcode, PackImage image) async {
final event = await ref
.read(ClientController.provider.notifier)
.sendEvent(
.new(
roomId: info.roomId,
type: EventType.sticker.type,
content: StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
),
);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new,

View file

@ -11,29 +11,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
void addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
state: .new(
state
.groupListsBy((event) => event.type)
.map(
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
),
),
),
}),
.new(),
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
update(
.new({
roomId: stateRoom(state)
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
}),
.new(),
);
void addSpecificState(Iterable<Event> state) => update(
.new(
state
.groupListsBy((event) => event.roomId)
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
),
);
static Room stateRoom(Iterable<Event> state) => Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
state: .new(
state
.groupListsBy((event) => event.type)
.map(
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
),
),
);
static IMap<String, IList<ReadReceipt>> mergeReceipts(

View file

@ -18,6 +18,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/emoji_picker.dart";
import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/main.dart";
extension BuildEventOptions on Event {
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
children: [
...{
// Custom emoji are sent without their shortcode, as only
// their mxc URL is recorded
...ref
.read(RecentEmojiController.provider)
.map((entry) => entry.emoji),
@ -109,10 +112,15 @@ extension BuildEventOptions on Event {
.take(isDesktop ? 3 : 5)
.map(
(emoji) => IconButton(
icon: Text(
emoji,
style: .new(fontSize: isDesktop ? null : 18),
),
icon: emoji.startsWith("mxc://")
? SizedBox.square(
dimension: isDesktop ? 18 : 24,
child: MxcEmoji(Uri.parse(emoji)),
)
: Text(
emoji,
style: .new(fontSize: isDesktop ? null : 18),
),
onPressed: () async {
Navigator.of(context).pop();
await ref
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
context.showAdaptiveSheet(
(context) => EmojiPicker(
roomId: roomId,
allowFreeText: true,
onSelection: (emoji) {
onSelection: (emoji, {shortcode}) {
Navigator.of(context).pop();
ref
.read(notifier)
.sendReaction(emoji, this)
.sendReaction(emoji, this, shortcode: shortcode)
.onError(showError);
},
),

View file

@ -0,0 +1,29 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/widgets/mxc_emoji.dart";
extension ImagePackEmoji on ImagePack {
EmojiCategory? get emojiCategory => _category(emoticons);
EmojiCategory? get stickerCategory => _category(stickers);
EmojiCategory? _category(IMap<String, PackImage> images) => images.isEmpty
? null
: .new(
icon: MxcEmoji(avatar ?? images.values.first.url),
name: name,
emojis: images
.mapTo(
(shortcode, image) => Emoji(
widget: MxcEmoji(image.url, label: shortcode),
value: image.url.toString(),
aliases: .new([shortcode]),
description: image.body ?? shortcode,
tags: const .empty(),
data: image,
),
)
.toIList(),
);
}

View file

@ -16,10 +16,24 @@ class const AccountData({
@JsonKey(name: AccountData.recentEmojiKey)
final RecentEmojiData recentEmoji = const RecentEmojiData(),
/// Room image packs the user has enabled everywhere
@JsonKey(
name: AccountData.imagePackRoomsKey,
fromJson: ImagePackRooms.tryFromJson,
)
final ImagePackRooms? imagePackRooms,
@JsonKey(
name: AccountData.legacyImagePackRoomsKey,
fromJson: ImagePackRooms.tryFromJson,
)
final ImagePackRooms? legacyImagePackRooms,
}) with _$AccountData {
static const invitePermissionConfigKey = "m.invite_permission_config";
static const directKey = "m.direct";
static const recentEmojiKey = "m.recent_emoji";
static const imagePackRoomsKey = "m.image_pack.rooms";
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
Map<String, Object?> toJson() => _$AccountDataToJson(this);
@ -38,6 +52,28 @@ class const RecentEmojiData({
_$RecentEmojiDataFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ImagePackRooms({
/// Room IDs to the state keys of their enabled packs, each to a (currently
/// empty) object
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
}) with _$ImagePackRooms {
static ImagePackRooms? tryFromJson(Map<String, dynamic>? json) {
if (json == null) return null;
try {
return .fromJson(json);
} catch (_) {
return null;
}
}
Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this);
factory ImagePackRooms.fromJson(Map<String, Object?> json) =>
_$ImagePackRoomsFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const InvitePermissionConfig({

View file

@ -16,6 +16,7 @@ import "package:nexus/models/content/server_acl.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart";
class const Content({final String? parseError, final StackTrace? stackTrace}) {
factory Content.fromJson(Map<String, dynamic> json) => const Content();
@ -61,5 +62,7 @@ enum EventType(
tombstone("m.room.tombstone", Content.fromJson),
reaction("m.reaction", ReactionContent.fromJson),
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
imagePack("m.room.image_pack", ImagePackContent.fromJson),
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
message("m.room.message", MessageContent.fromJson),
}

View file

@ -0,0 +1,68 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/info/image.dart";
part "image_pack.freezed.dart";
part "image_pack.g.dart";
/// An image pack (MSC2545), from a room's state.
@freezed
sealed class ImagePackContent extends Content with _$ImagePackContent {
ImagePackContent._();
factory ImagePackContent({
@Default(IMap.empty()) IMap<String, PackImage> images,
PackInfo? pack,
}) = _ImagePackContent;
IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where(
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
(final usages, _) when usages.isNotEmpty => usages.contains(usage),
(_, final packUsages) when packUsages.isNotEmpty => packUsages.contains(
usage,
),
_ => true,
},
);
factory ImagePackContent.fromJson(Map<String, Object?> json) =>
_$ImagePackContentFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PackImage({
required final Uri url,
final String? body,
final ImageInfo? info,
@JsonKey(fromJson: ImageUsage.setFromJson)
final ISet<ImageUsage> usage = const .empty(),
}) with _$PackImage {
Map<String, Object?> toJson() => _$PackImageToJson(this);
factory PackImage.fromJson(Map<String, Object?> json) =>
_$PackImageFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PackInfo({
final String? displayName,
final Uri? avatarUrl,
final String? attribution,
@JsonKey(fromJson: ImageUsage.setFromJson)
final ISet<ImageUsage> usage = const .empty(),
}) with _$PackInfo {
Map<String, Object?> toJson() => _$PackInfoToJson(this);
factory PackInfo.fromJson(Map<String, Object?> json) =>
_$PackInfoFromJson(json);
}
enum ImageUsage {
emoticon,
sticker;
/// Ignores usages we don't know about.
static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
ISet(values.where((value) => json?.contains(value.name) ?? false));
}

View file

@ -11,11 +11,15 @@ sealed class ReactionContent extends Content with _$ReactionContent {
factory ReactionContent({
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
/// For custom emoji reactions
@JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode,
}) = _ReactionContent;
@override
Map<String, dynamic> toJson() => {
"m.relates_to": {"key": key},
"com.beeper.reaction.shortcode": ?shortcode,
};
factory ReactionContent.fromJson(Map<String, Object?> json) =>

View file

@ -0,0 +1,33 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/image_pack.dart";
part "image_pack.freezed.dart";
@Freezed(toJson: false, fromJson: false)
class const ImagePack({
required final String id,
required final String name,
final Uri? avatar,
/// Map of shortcode to images
required final IMap<String, PackImage> emoticons,
required final IMap<String, PackImage> stickers,
}) with _$ImagePack {
static String emojiMarkdown(String shortcode, String url) {
final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"');
return '![:$shortcode:]($url "Emoji: $title")';
}
factory ImagePack.fromContent(
ImagePackContent content, {
required String id,
required String fallbackName,
}) => .new(
id: id,
name: content.pack?.displayName ?? fallbackName,
avatar: content.pack?.avatarUrl,
emoticons: content.imagesFor(.emoticon),
stickers: content.imagesFor(.sticker),
);
}

View file

@ -0,0 +1,29 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "get_specific_room_state.freezed.dart";
part "get_specific_room_state.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const GetSpecificRoomStateRequest({
required final IList<RoomStateKey> keys,
}) with _$GetSpecificRoomStateRequest {
Map<String, Object?> toJson() => _$GetSpecificRoomStateRequestToJson(this);
factory GetSpecificRoomStateRequest.fromJson(Map<String, Object?> json) =>
_$GetSpecificRoomStateRequestFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RoomStateKey({
required final String roomId,
required final String type,
required final String stateKey,
}) with _$RoomStateKey {
Map<String, Object?> toJson() => _$RoomStateKeyToJson(this);
factory RoomStateKey.fromJson(Map<String, Object?> json) =>
_$RoomStateKeyFromJson(json);
}

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);
}
},
);
},
);
}

View file

@ -924,10 +924,10 @@ packages:
dependency: "direct main"
description:
name: material_emoji_picker
sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138"
sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "2.0.1"
material_ui:
dependency: "direct main"
description:

View file

@ -111,7 +111,7 @@ dependencies:
unifiedpush_storage_shared_preferences: 1.0.0
flutter_local_notifications: 22.3.1
timezone: 0.11.1
material_emoji_picker: 1.2.0
material_emoji_picker: 2.0.1
xdg_desktop_portal:
git:
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart