add settings for image packs

Closes #131
This commit is contained in:
Henry Hiles 2026-10-26 12:05:45 -04:00
commit 4b329b4c6f
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 1115 additions and 119 deletions

View file

@ -29,6 +29,9 @@ class const AccountData({
static const imagePackRoomsKey = "m.image_pack.rooms";
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
ImagePackRooms get enabledImagePacks =>
imagePackRooms ?? legacyImagePackRooms ?? const .new();
Map<String, Object?> toJson() => _$AccountDataToJson(this);
factory AccountData.fromJson(Map<String, Object?> json) =>
@ -53,6 +56,29 @@ class const ImagePackRooms({
/// empty) object
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
}) with _$ImagePackRooms {
bool contains(String roomId, String stateKey) =>
rooms[roomId]?.containsKey(stateKey) ?? false;
ImagePackRooms withPack(
String roomId,
String stateKey, {
required bool enabled,
}) {
final packs =
rooms[roomId] ?? const IMap<String, Map<String, dynamic>>.empty();
final updated = switch ((enabled, packs.containsKey(stateKey))) {
(true, false) => packs.add(stateKey, const {}),
(false, true) => packs.remove(stateKey),
_ => packs,
};
return copyWith(
rooms: updated.isEmpty
? rooms.remove(roomId)
: rooms.add(roomId, updated),
);
}
Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this);
factory ImagePackRooms.fromJson(Map<String, Object?> json) =>

View file

@ -7,11 +7,14 @@ part "image_pack.g.dart";
/// An image pack (MSC2545), from a room's state.
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
@JsonSerializable(includeIfNull: false)
class const ImagePackContent({
final IMap<String, PackImage> images = const .empty(),
final PackInfo? pack,
}) extends Content with _$ImagePackContent {
/// Whether the pack has images or a name, as deleted packs are left empty.
bool get hasContent => images.isNotEmpty || pack?.displayName != null;
IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where(
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
(final usages, _) when usages.isNotEmpty => usages.contains(usage),
@ -30,12 +33,12 @@ class const ImagePackContent({
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
@JsonSerializable(includeIfNull: false)
class const PackImage({
required final Uri url,
final String? body,
final ImageInfo? info,
@JsonKey(fromJson: ImageUsage.setFromJson)
@JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
final ISet<ImageUsage> usage = const .empty(),
}) with _$PackImage {
Map<String, Object?> toJson() => _$PackImageToJson(this);
@ -45,12 +48,12 @@ class const PackImage({
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
@JsonSerializable(includeIfNull: false)
class const PackInfo({
final String? displayName,
final Uri? avatarUrl,
final String? attribution,
@JsonKey(fromJson: ImageUsage.setFromJson)
@JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
final ISet<ImageUsage> usage = const .empty(),
}) with _$PackInfo {
Map<String, Object?> toJson() => _$PackInfoToJson(this);
@ -66,4 +69,8 @@ enum ImageUsage {
/// Ignores usages we don't know about.
static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
ISet(values.where((value) => json?.contains(value.name) ?? false));
/// Omits an empty set, which means every usage.
static List<String>? setToJson(ISet<ImageUsage> usages) =>
usages.isEmpty ? null : usages.map((usage) => usage.name).toList();
}

View file

@ -13,21 +13,42 @@ class const ImagePack({
/// Map of shortcode to images
required final IMap<String, PackImage> emoticons,
required final IMap<String, PackImage> stickers,
/// The state event the pack is from, for editing it.
required final ImagePackContent content,
}) with _$ImagePack {
static String emojiMarkdown(String shortcode, String url) {
final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"');
return '![:$shortcode:]($url "Emoji: $title")';
}
/// The pack's avatar, or its first image if it has none.
Uri? get icon => avatar ?? content.images.values.firstOrNull?.url;
String get imageCountLabel => content.images.length == 1
? "1 image"
: "${content.images.length} images";
/// The name of a pack without one, from its room's name and state key.
static String fallbackName(String roomName, String stateKey) =>
stateKey.isEmpty ? roomName : "$roomName - $stateKey";
static String emojiMarkdown(
String shortcode,
String url, {
String? description,
}) =>
'![:$shortcode:]($url "Emoji: ${switch (description) {
final description? when description.isNotEmpty => description,
_ => ":$shortcode:",
}.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")';
factory ImagePack.fromContent(
ImagePackContent content, {
required String id,
required String fallbackName,
required String roomId,
required String roomName,
required String stateKey,
}) => .new(
id: id,
name: content.pack?.displayName ?? fallbackName,
id: "$roomId/$stateKey",
name: content.pack?.displayName ?? fallbackName(roomName, stateKey),
avatar: content.pack?.avatarUrl,
emoticons: content.imagesFor(.emoticon),
stickers: content.imagesFor(.sticker),
content: content,
);
}