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
7 changed files with 1115 additions and 119 deletions

View file

@ -1,8 +1,10 @@
import "dart:async";
import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart";
class AccountDataController extends Notifier<AccountData> {
@ -22,6 +24,44 @@ class AccountDataController extends Notifier<AccountData> {
}
});
Future<void> setEnabledImagePacks(ImagePackRooms packs) async {
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
.toISet();
final keys = keysOf(packs);
bool arrived(AccountData data) {
final current = keysOf(data.enabledImagePacks);
return current.length == keys.length && current.containsAll(keys);
}
final completer = Completer<void>();
final removeListener = listenSelf((_, data) {
if (arrived(data) && !completer.isCompleted) completer.complete();
});
try {
final client = ref.read(ClientController.provider.notifier);
await Future.wait([
if (state.imagePackRooms != null)
client.setAccountData(
.new(type: AccountData.imagePackRoomsKey, content: packs),
),
client.setAccountData(
.new(type: AccountData.legacyImagePackRoomsKey, content: packs),
),
]);
if (!arrived(state)) {
await completer.future.timeout(
const .new(seconds: 10),
onTimeout: () {},
);
}
} finally {
removeListener();
}
}
static final provider = NotifierProvider<AccountDataController, AccountData>(
AccountDataController.new,
);

View file

@ -0,0 +1,69 @@
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/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
/// The image packs the user has enabled everywhere, as room IDs to their
/// packs' state keys, once the packs' state has been loaded.
class EnabledImagePacksController
extends AsyncNotifier<IMap<String, ISet<String>>> {
@override
Future<IMap<String, ISet<String>>> build() async {
final enabledPacks = ref.watch(
AccountDataController.provider.select(
(data) => data.enabledImagePacks.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
),
),
);
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack.
final missingKeys = ref.watch(
RoomsController.provider.select(
(rooms) => 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) {
ref
.read(RoomsController.provider.notifier)
.addSpecificState(
await ref
.read(ClientController.provider.notifier)
.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return enabledPacks;
}
static final provider =
AsyncNotifierProvider.autoDispose<
EnabledImagePacksController,
IMap<String, ISet<String>>
>(EnabledImagePacksController.new);
}

View file

@ -1,11 +1,9 @@
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/enabled_image_packs.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});
@ -13,14 +11,8 @@ 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 enabledPacksFuture = ref.watch(
EnabledImagePacksController.provider.future,
);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final directParents = ref.watch(
@ -63,36 +55,7 @@ class ImagePackSourcesController(final String roomId)
),
),
);
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)),
);
}
final enabledPacks = await enabledPacksFuture;
return .new([
(roomId: roomId, stateKey: null),

View file

@ -2,9 +2,7 @@ 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/helpers/extensions/room_image_packs.dart";
import "package:nexus/models/image_pack.dart";
class ImagePacksController(final String roomId)
@ -18,40 +16,13 @@ class ImagePacksController(final String roomId)
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;
final packs =
rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty();
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;
final stateKey? => [?packs[stateKey]],
null => packs.values,
};
}).toIList(),
),
);

View file

@ -0,0 +1,37 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/models/image_pack.dart";
/// The image packs in [roomId]'s own state by state key, loading its state if
/// it hasn't been yet.
class RoomImagePacksController(final String roomId)
extends AsyncNotifier<IMap<String, ImagePack>> {
@override
Future<IMap<String, ImagePack>> build() async {
if (ref.read(RoomsController.provider)[roomId] case final room?
when !room.hasFetchedState) {
ref
.read(RoomsController.provider.notifier)
.addState(
roomId,
await ref
.read(ClientController.provider.notifier)
.getRoomState(.new(roomId: roomId)),
);
}
return ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(),
),
);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<RoomImagePacksController, IMap<String, ImagePack>, String>(
RoomImagePacksController.new,
);
}

View file

@ -7,6 +7,7 @@ import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/power_levels_saving.dart";
import "package:nexus/controllers/room_image_packs.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
@ -21,6 +22,7 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart";
@ -33,10 +35,12 @@ import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/image_pack_sheet.dart";
import "package:nexus/widgets/settings/local_aliases_sheet.dart";
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
import "package:nexus/widgets/settings/power_level_tile.dart";
@ -91,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId)
final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias);
final canCreatePacks = canSet(.imagePack);
final canSetPowerLevels =
canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId));
@ -161,15 +166,78 @@ class RoomSettingsSectionsController(final String roomId)
final encrypted = stateContent(.encryption) is EncryptionContent;
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
Future<void> setState(EventType type, Content content) async {
Future<void> setState(
EventType type,
Content content, {
String stateKey = "",
}) async {
final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
.new(
roomId: roomId,
type: type.type,
stateKey: stateKey,
content: content,
),
);
if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId);
await roomsController.awaitState(
roomId,
type.type,
eventId,
stateKey: stateKey,
);
}
}
final imagePacks =
ref.watch(RoomImagePacksController.provider(roomId)).value ??
const .empty();
// Pack sheets can stay open while the state changes, so these read it when
// called, rather than using the state from building
IMap<String, IMap<String, int>> currentState() =>
ref.read(RoomsController.provider)[roomId]?.state ?? const .empty();
// Whether the legacy type still has a pack at [stateKey], to clear it
bool hasLegacyPack(String stateKey) =>
switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) {
final rowId? => switch (contentOf(rowId)) {
final ImagePackContent content => content.hasContent,
_ => false,
},
null => false,
};
final canSetLegacyPacks = canSet(.legacyImagePack);
/// Saves the pack at [stateKey] as the stable type, migrating it by
/// clearing its legacy event if it has one.
Future<void> setPack(String stateKey, Content content) async {
await setState(.imagePack, content, stateKey: stateKey);
if (hasLegacyPack(stateKey)) {
await setState(.legacyImagePack, const .new(), stateKey: stateKey);
}
}
/// A state key for a new pack named [name]. It's one no pack has ever
/// had, even deleted ones, as users may still have those enabled.
String newPackKey(String name) {
final state = currentState();
final usedKeys = [
EventType.imagePack,
EventType.legacyImagePack,
].expand((type) => state[type.type]?.keys ?? []).toISet();
final slug = name
.toLowerCase()
.replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_")
.replaceAll(RegExp(r"^_+|_+$"), "");
final base = slug.isEmpty ? "pack" : slug;
return Iterable.generate(
usedKeys.length + 1,
(index) => index == 0 ? base : "${base}_${index + 1}",
).firstWhere((key) => !usedKeys.contains(key));
}
Future<void> setAliases(
String? canonical,
ISet<String> alternatives,
@ -817,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId)
),
]),
),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...imagePacks.entries.map((entry) {
final stateKey = entry.key;
final pack = entry.value;
final canEdit =
canCreatePacks &&
(canSetLegacyPacks || !hasLegacyPack(stateKey));
return Setting(
title: pack.name,
description: pack.imageCountLabel,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey(stateKey),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(pack.icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: canEdit ? "Edit Image Pack" : title,
content: pack.content,
onSave: canEdit
? (content) => saving.run(
() => setPack(
stateKey,
content,
).onError(showError),
)
: null,
onDeleted: canEdit
? () => saving.run(
() => setPack(
stateKey,
const .new(),
).onError(showError),
)
: null,
),
),
);
},
),
);
}),
.new(
title: "Create Pack",
description: "Add a pack of emoticons and stickers to the room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canCreatePacks && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: "Create Pack",
content: const .new(),
onSave: (content) => saving.run(
() => setState(
.imagePack,
content,
stateKey: newPackKey(content.pack!.displayName!),
).onError(showError),
),
),
),
);
},
),
),
]),
),
]),
});
}

View file

@ -9,23 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/enabled_image_packs.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/models/name_color.dart";
import "package:nexus/models/room_order.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -36,6 +44,33 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future);
// Loads the enabled packs' state, so they can be shown
ref.watch(EnabledImagePacksController.provider);
final enabledPacks = ref.watch(
AccountDataController.provider.select((data) => data.enabledImagePacks),
);
final enabledPackInfo = ref.watch(
RoomsController.provider.select(
(rooms) => enabledPacks.rooms.entries.expand((entry) {
final room = rooms[entry.key];
final packs = room?.imagePacks(entry.key) ?? const .empty();
return entry.value.keys.map(
(stateKey) => (
roomId: entry.key,
stateKey: stateKey,
roomName: room?.metadata?.name ?? entry.key,
pack: packs[stateKey],
),
);
}).toIList(),
),
);
Future<void> setEnabledPacks(ImagePackRooms packs) => ref
.read(AccountDataController.provider.notifier)
.setEnabledImagePacks(packs);
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
Widget showAndSendTile(
String title,
@ -300,6 +335,78 @@ class SettingsSectionsController
),
]),
),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...enabledPackInfo.map(
(info) => Setting(
title:
info.pack?.name ??
ImagePack.fallbackName(info.roomName, info.stateKey),
description: info.roomName,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey((info.roomId, info.stateKey)),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(info.pack?.icon),
title: Text(title),
subtitle: Text(description),
trailing: IconButton(
tooltip: "Remove pack",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => saving.run(
() => setEnabledPacks(
ref
.read(AccountDataController.provider)
.enabledImagePacks
.withPack(
info.roomId,
info.stateKey,
enabled: false,
),
).onError(showError),
),
),
);
},
),
),
),
.new(
title: "Add Packs",
description:
"Use image packs from any of your rooms in every room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => EnableImagePacksSheet(
enabled: enabledPacks,
onSave: (packs) => saving.run(
() => setEnabledPacks(packs).onError(showError),
),
),
),
);
},
),
),
]),
),
.new(
title: "Notifications",
icon: Icons.notifications,