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

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