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
7 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(