parent
2041b81141
commit
4b329b4c6f
20 changed files with 1115 additions and 119 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
|
import "dart:async";
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
|
||||||
class AccountDataController extends Notifier<AccountData> {
|
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>(
|
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
||||||
AccountDataController.new,
|
AccountDataController.new,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
69
lib/controllers/enabled_image_packs.dart
Normal file
69
lib/controllers/enabled_image_packs.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.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/client.dart";
|
||||||
|
import "package:nexus/controllers/enabled_image_packs.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/space_edges.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});
|
typedef ImagePackSource = ({String roomId, String? stateKey});
|
||||||
|
|
||||||
|
|
@ -13,14 +11,8 @@ class ImagePackSourcesController(final String roomId)
|
||||||
extends AsyncNotifier<IList<ImagePackSource>> {
|
extends AsyncNotifier<IList<ImagePackSource>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<ImagePackSource>> build() async {
|
Future<IList<ImagePackSource>> build() async {
|
||||||
final enabledPacks = ref.watch(
|
final enabledPacksFuture = ref.watch(
|
||||||
AccountDataController.provider.select(
|
EnabledImagePacksController.provider.future,
|
||||||
(data) =>
|
|
||||||
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
|
|
||||||
(roomId, packs) => .new(roomId, packs.keys.toISet()),
|
|
||||||
) ??
|
|
||||||
const .empty(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||||
final directParents = ref.watch(
|
final directParents = ref.watch(
|
||||||
|
|
@ -63,36 +55,7 @@ class ImagePackSourcesController(final String roomId)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final enabledPacks = await enabledPacksFuture;
|
||||||
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([
|
return .new([
|
||||||
(roomId: roomId, stateKey: null),
|
(roomId: roomId, stateKey: null),
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/image_pack_sources.dart";
|
import "package:nexus/controllers/image_pack_sources.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/helpers/extensions/room_image_packs.dart";
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
|
||||||
import "package:nexus/models/event.dart";
|
|
||||||
import "package:nexus/models/image_pack.dart";
|
import "package:nexus/models/image_pack.dart";
|
||||||
|
|
||||||
class ImagePacksController(final String roomId)
|
class ImagePacksController(final String roomId)
|
||||||
|
|
@ -18,40 +16,13 @@ class ImagePacksController(final String roomId)
|
||||||
final packs = ref.watch(
|
final packs = ref.watch(
|
||||||
RoomsController.provider.select(
|
RoomsController.provider.select(
|
||||||
(rooms) => sources.expand((source) {
|
(rooms) => sources.expand((source) {
|
||||||
final room = rooms[source.roomId];
|
final packs =
|
||||||
if (room == null) return const <ImagePack>[];
|
rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty();
|
||||||
|
|
||||||
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) {
|
return switch (source.stateKey) {
|
||||||
final stateKey? => ISet([stateKey]),
|
final stateKey? => [?packs[stateKey]],
|
||||||
null => ISet(
|
null => packs.values,
|
||||||
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(),
|
}).toIList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
37
lib/controllers/room_image_packs.dart
Normal file
37
lib/controllers/room_image_packs.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/local_aliases.dart";
|
import "package:nexus/controllers/local_aliases.dart";
|
||||||
import "package:nexus/controllers/power_level.dart";
|
import "package:nexus/controllers/power_level.dart";
|
||||||
import "package:nexus/controllers/power_levels_saving.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/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/space_edges.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/create.dart";
|
||||||
import "package:nexus/models/content/encryption.dart";
|
import "package:nexus/models/content/encryption.dart";
|
||||||
import "package:nexus/models/content/history_visibility.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/join_rules.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/content/message.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/models/settings_category.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/divider_text.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/add_users_power_level_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/aliases_sheet.dart";
|
import "package:nexus/widgets/settings/aliases_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/allowed_rooms_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/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/local_aliases_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
|
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/power_level_tile.dart";
|
import "package:nexus/widgets/settings/power_level_tile.dart";
|
||||||
|
|
@ -91,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
final canSetJoinRules = canSet(.joinRules);
|
final canSetJoinRules = canSet(.joinRules);
|
||||||
final canSetHistoryVisibility = canSet(.historyVisibility);
|
final canSetHistoryVisibility = canSet(.historyVisibility);
|
||||||
final canSetAliases = canSet(.canonicalAlias);
|
final canSetAliases = canSet(.canonicalAlias);
|
||||||
|
final canCreatePacks = canSet(.imagePack);
|
||||||
final canSetPowerLevels =
|
final canSetPowerLevels =
|
||||||
canSet(.powerLevels) &&
|
canSet(.powerLevels) &&
|
||||||
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
||||||
|
|
@ -161,15 +166,78 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
final encrypted = stateContent(.encryption) is EncryptionContent;
|
final encrypted = stateContent(.encryption) is EncryptionContent;
|
||||||
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
|
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(
|
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?) {
|
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(
|
Future<void> setAliases(
|
||||||
String? canonical,
|
String? canonical,
|
||||||
ISet<String> alternatives,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
import "package:nexus/controllers/account_data.dart";
|
import "package:nexus/controllers/account_data.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.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/init_complete.dart";
|
||||||
import "package:nexus/controllers/notification.dart";
|
import "package:nexus/controllers/notification.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/controllers/settings.dart";
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/controllers/unified_push.dart";
|
import "package:nexus/controllers/unified_push.dart";
|
||||||
import "package:nexus/controllers/spec_versions.dart";
|
import "package:nexus/controllers/spec_versions.dart";
|
||||||
import "package:nexus/controllers/unified_push_allowed.dart";
|
import "package:nexus/controllers/unified_push_allowed.dart";
|
||||||
import "package:nexus/controllers/unified_push_distributors.dart";
|
import "package:nexus/controllers/unified_push_distributors.dart";
|
||||||
import "package:nexus/helpers/extensions/distributor_name.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/account_data.dart";
|
||||||
import "package:nexus/models/client_state.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/name_color.dart";
|
||||||
import "package:nexus/models/room_order.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.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/models/show_and_send.dart";
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.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/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/user_bottom_sheet.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
|
@ -36,6 +44,33 @@ class SettingsSectionsController
|
||||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||||
final settings = await ref.watch(SettingsController.provider.future);
|
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].
|
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
|
||||||
Widget showAndSendTile(
|
Widget showAndSendTile(
|
||||||
String title,
|
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(
|
.new(
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
icon: Icons.notifications,
|
icon: Icons.notifications,
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ extension BuildEventOptions on Event {
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
allowFreeText: true,
|
allowFreeText: true,
|
||||||
onSelection: (emoji, {shortcode}) {
|
onSelection: (emoji, {shortcode, description}) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ref
|
ref
|
||||||
.read(notifier)
|
.read(notifier)
|
||||||
|
|
|
||||||
34
lib/helpers/extensions/room_image_packs.dart
Normal file
34
lib/helpers/extensions/room_image_packs.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.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";
|
||||||
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
|
extension RoomImagePacks on Room {
|
||||||
|
/// The room's image packs by state key, preferring the stable type when a
|
||||||
|
/// pack has both, and leaving out deleted ones. [roomId] names those without
|
||||||
|
/// a name when the room has none either.
|
||||||
|
IMap<String, ImagePack> imagePacks(String roomId) => .fromEntries(
|
||||||
|
const [EventType.legacyImagePack, EventType.imagePack].expand(
|
||||||
|
(type) => (state[type.type] ?? .new()).entries
|
||||||
|
.map(
|
||||||
|
(entry) => switch (events[entry.value]) {
|
||||||
|
Event(redactedBy: null, content: final ImagePackContent content)
|
||||||
|
when content.hasContent =>
|
||||||
|
MapEntry(
|
||||||
|
entry.key,
|
||||||
|
ImagePack.fromContent(
|
||||||
|
content,
|
||||||
|
roomId: roomId,
|
||||||
|
roomName: metadata?.name ?? roomId,
|
||||||
|
stateKey: entry.key,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.nonNulls,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,9 @@ class const AccountData({
|
||||||
static const imagePackRoomsKey = "m.image_pack.rooms";
|
static const imagePackRoomsKey = "m.image_pack.rooms";
|
||||||
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
|
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
|
||||||
|
|
||||||
|
ImagePackRooms get enabledImagePacks =>
|
||||||
|
imagePackRooms ?? legacyImagePackRooms ?? const .new();
|
||||||
|
|
||||||
Map<String, Object?> toJson() => _$AccountDataToJson(this);
|
Map<String, Object?> toJson() => _$AccountDataToJson(this);
|
||||||
|
|
||||||
factory AccountData.fromJson(Map<String, Object?> json) =>
|
factory AccountData.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
@ -53,6 +56,29 @@ class const ImagePackRooms({
|
||||||
/// empty) object
|
/// empty) object
|
||||||
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
|
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
|
||||||
}) with _$ImagePackRooms {
|
}) 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);
|
Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this);
|
||||||
|
|
||||||
factory ImagePackRooms.fromJson(Map<String, Object?> json) =>
|
factory ImagePackRooms.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ part "image_pack.g.dart";
|
||||||
|
|
||||||
/// An image pack (MSC2545), from a room's state.
|
/// An image pack (MSC2545), from a room's state.
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable(includeIfNull: false)
|
||||||
class const ImagePackContent({
|
class const ImagePackContent({
|
||||||
final IMap<String, PackImage> images = const .empty(),
|
final IMap<String, PackImage> images = const .empty(),
|
||||||
final PackInfo? pack,
|
final PackInfo? pack,
|
||||||
}) extends Content with _$ImagePackContent {
|
}) 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(
|
IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where(
|
||||||
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
|
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
|
||||||
(final usages, _) when usages.isNotEmpty => usages.contains(usage),
|
(final usages, _) when usages.isNotEmpty => usages.contains(usage),
|
||||||
|
|
@ -30,12 +33,12 @@ class const ImagePackContent({
|
||||||
}
|
}
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable(includeIfNull: false)
|
||||||
class const PackImage({
|
class const PackImage({
|
||||||
required final Uri url,
|
required final Uri url,
|
||||||
final String? body,
|
final String? body,
|
||||||
final ImageInfo? info,
|
final ImageInfo? info,
|
||||||
@JsonKey(fromJson: ImageUsage.setFromJson)
|
@JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
|
||||||
final ISet<ImageUsage> usage = const .empty(),
|
final ISet<ImageUsage> usage = const .empty(),
|
||||||
}) with _$PackImage {
|
}) with _$PackImage {
|
||||||
Map<String, Object?> toJson() => _$PackImageToJson(this);
|
Map<String, Object?> toJson() => _$PackImageToJson(this);
|
||||||
|
|
@ -45,12 +48,12 @@ class const PackImage({
|
||||||
}
|
}
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable(includeIfNull: false)
|
||||||
class const PackInfo({
|
class const PackInfo({
|
||||||
final String? displayName,
|
final String? displayName,
|
||||||
final Uri? avatarUrl,
|
final Uri? avatarUrl,
|
||||||
final String? attribution,
|
final String? attribution,
|
||||||
@JsonKey(fromJson: ImageUsage.setFromJson)
|
@JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
|
||||||
final ISet<ImageUsage> usage = const .empty(),
|
final ISet<ImageUsage> usage = const .empty(),
|
||||||
}) with _$PackInfo {
|
}) with _$PackInfo {
|
||||||
Map<String, Object?> toJson() => _$PackInfoToJson(this);
|
Map<String, Object?> toJson() => _$PackInfoToJson(this);
|
||||||
|
|
@ -66,4 +69,8 @@ enum ImageUsage {
|
||||||
/// Ignores usages we don't know about.
|
/// Ignores usages we don't know about.
|
||||||
static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
|
static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
|
||||||
ISet(values.where((value) => json?.contains(value.name) ?? false));
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,42 @@ class const ImagePack({
|
||||||
/// Map of shortcode to images
|
/// Map of shortcode to images
|
||||||
required final IMap<String, PackImage> emoticons,
|
required final IMap<String, PackImage> emoticons,
|
||||||
required final IMap<String, PackImage> stickers,
|
required final IMap<String, PackImage> stickers,
|
||||||
|
|
||||||
|
/// The state event the pack is from, for editing it.
|
||||||
|
required final ImagePackContent content,
|
||||||
}) with _$ImagePack {
|
}) with _$ImagePack {
|
||||||
static String emojiMarkdown(String shortcode, String url) {
|
/// The pack's avatar, or its first image if it has none.
|
||||||
final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"');
|
Uri? get icon => avatar ?? content.images.values.firstOrNull?.url;
|
||||||
return '';
|
|
||||||
}
|
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,
|
||||||
|
}) =>
|
||||||
|
' {
|
||||||
|
final description? when description.isNotEmpty => description,
|
||||||
|
_ => ":$shortcode:",
|
||||||
|
}.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")';
|
||||||
|
|
||||||
factory ImagePack.fromContent(
|
factory ImagePack.fromContent(
|
||||||
ImagePackContent content, {
|
ImagePackContent content, {
|
||||||
required String id,
|
required String roomId,
|
||||||
required String fallbackName,
|
required String roomName,
|
||||||
|
required String stateKey,
|
||||||
}) => .new(
|
}) => .new(
|
||||||
id: id,
|
id: "$roomId/$stateKey",
|
||||||
name: content.pack?.displayName ?? fallbackName,
|
name: content.pack?.displayName ?? fallbackName(roomName, stateKey),
|
||||||
avatar: content.pack?.avatarUrl,
|
avatar: content.pack?.avatarUrl,
|
||||||
emoticons: content.imagesFor(.emoticon),
|
emoticons: content.imagesFor(.emoticon),
|
||||||
stickers: content.imagesFor(.sticker),
|
stickers: content.imagesFor(.sticker),
|
||||||
|
content: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,8 @@ class const Composer(
|
||||||
onPressed: () => context.showAdaptiveSheet(
|
onPressed: () => context.showAdaptiveSheet(
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
onSelection: (value, {shortcode}) {
|
onSelection:
|
||||||
|
(value, {shortcode, description}) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (shortcode == null) {
|
if (shortcode == null) {
|
||||||
controller.text += value;
|
controller.text += value;
|
||||||
|
|
@ -287,6 +288,7 @@ class const Composer(
|
||||||
id: ImagePack.emojiMarkdown(
|
id: ImagePack.emojiMarkdown(
|
||||||
shortcode,
|
shortcode,
|
||||||
value,
|
value,
|
||||||
|
description: description,
|
||||||
),
|
),
|
||||||
name: shortcode,
|
name: shortcode,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/all_emoji.dart";
|
import "package:nexus/controllers/all_emoji.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
import "package:nexus/models/image_pack.dart";
|
import "package:nexus/models/image_pack.dart";
|
||||||
|
|
||||||
class const EmojiOverlay(
|
class const EmojiOverlay(
|
||||||
|
|
@ -39,6 +40,10 @@ class const EmojiOverlay(
|
||||||
? ImagePack.emojiMarkdown(
|
? ImagePack.emojiMarkdown(
|
||||||
emoji.aliases.first,
|
emoji.aliases.first,
|
||||||
emoji.value,
|
emoji.value,
|
||||||
|
description: switch (emoji.data) {
|
||||||
|
PackImage(:final body) => body,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
: emoji.value,
|
: emoji.value,
|
||||||
name: emoji.aliases.first,
|
name: emoji.aliases.first,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,12 @@ import "package:nexus/models/content/image_pack.dart";
|
||||||
class const EmojiPicker({
|
class const EmojiPicker({
|
||||||
required final String roomId,
|
required final String roomId,
|
||||||
|
|
||||||
required final FutureOr<void> Function(String value, {String? shortcode})
|
/// Has the [shortcode] and [description] of images from packs.
|
||||||
|
required final FutureOr<void> Function(
|
||||||
|
String value, {
|
||||||
|
String? shortcode,
|
||||||
|
String? description,
|
||||||
|
})
|
||||||
onSelection,
|
onSelection,
|
||||||
final bool allowFreeText = false,
|
final bool allowFreeText = false,
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -41,11 +46,14 @@ class const EmojiPicker({
|
||||||
|
|
||||||
return upstream.EmojiPicker(
|
return upstream.EmojiPicker(
|
||||||
onSelection: (emoji) async {
|
onSelection: (emoji) async {
|
||||||
|
final image = switch (emoji.data) {
|
||||||
|
final PackImage image => image,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
await onSelection(
|
await onSelection(
|
||||||
emoji.value,
|
emoji.value,
|
||||||
shortcode: emoji.data is PackImage
|
shortcode: image == null ? null : emoji.aliases.first,
|
||||||
? emoji.aliases.first
|
description: image?.body,
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
|
|
|
||||||
14
lib/widgets/image_pack_avatar.dart
Normal file
14
lib/widgets/image_pack_avatar.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
|
|
||||||
|
class const ImagePackAvatar(final Uri? icon, {super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox.square(
|
||||||
|
dimension: 40,
|
||||||
|
child: switch (icon) {
|
||||||
|
final icon? => MxcEmoji(icon),
|
||||||
|
null => Icon(Icons.emoji_emotions),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
118
lib/widgets/settings/enable_image_packs_sheet.dart
Normal file
118
lib/widgets/settings/enable_image_packs_sheet.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/room_image_packs.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/models/account_data.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/image_pack_avatar.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
/// Chooses a room, then which of its image packs to use in every room.
|
||||||
|
class const EnableImagePacksSheet({
|
||||||
|
required final ImagePackRooms enabled,
|
||||||
|
required final void Function(ImagePackRooms enabled) onSave,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final chosen = useState(enabled);
|
||||||
|
final roomId = useState<String?>(null);
|
||||||
|
final rooms = ref.watch(
|
||||||
|
RoomsController.provider.select(
|
||||||
|
(rooms) => rooms.values
|
||||||
|
.map((room) => room.metadata)
|
||||||
|
.nonNulls
|
||||||
|
.map(
|
||||||
|
(metadata) => (
|
||||||
|
id: metadata.id,
|
||||||
|
name: metadata.name ?? metadata.id,
|
||||||
|
avatar: metadata.avatar,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.sortedBy((room) => room.name.toLowerCase())
|
||||||
|
.toIList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: Text("Add Image Packs"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onSave(chosen.value);
|
||||||
|
},
|
||||||
|
child: Text("Save"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
Text("Packs you add can be used in every room."),
|
||||||
|
DropdownMenu(
|
||||||
|
expandedInsets: .zero,
|
||||||
|
menuHeight: 300,
|
||||||
|
enableFilter: true,
|
||||||
|
requestFocusOnTap: true,
|
||||||
|
label: Text("Room"),
|
||||||
|
onSelected: (id) => roomId.value = id,
|
||||||
|
dropdownMenuEntries: rooms
|
||||||
|
.map(
|
||||||
|
(room) => DropdownMenuEntry(
|
||||||
|
value: room.id,
|
||||||
|
label: room.name,
|
||||||
|
leadingIcon: AvatarOrHash(
|
||||||
|
room.avatar,
|
||||||
|
room.name,
|
||||||
|
fallback: Icon(Icons.numbers),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
alignment: .topCenter,
|
||||||
|
child: switch (roomId.value) {
|
||||||
|
final roomId? =>
|
||||||
|
ref
|
||||||
|
.watch(RoomImagePacksController.provider(roomId))
|
||||||
|
.betterWhen(
|
||||||
|
data: (packs) => packs.isEmpty
|
||||||
|
? Text("This room has no image packs")
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: packs.entries
|
||||||
|
.map(
|
||||||
|
(entry) => CheckboxListTile(
|
||||||
|
secondary: ImagePackAvatar(
|
||||||
|
entry.value.icon,
|
||||||
|
),
|
||||||
|
title: Text(entry.value.name),
|
||||||
|
subtitle: Text(entry.value.imageCountLabel),
|
||||||
|
value: chosen.value.contains(
|
||||||
|
roomId,
|
||||||
|
entry.key,
|
||||||
|
),
|
||||||
|
onChanged: (value) =>
|
||||||
|
chosen.value = chosen.value.withPack(
|
||||||
|
roomId,
|
||||||
|
entry.key,
|
||||||
|
enabled: value ?? false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
null => const SizedBox(width: .infinity),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
250
lib/widgets/settings/image_pack_sheet.dart
Normal file
250
lib/widgets/settings/image_pack_sheet.dart
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
|
import "package:nexus/models/content/message.dart";
|
||||||
|
import "package:nexus/widgets/image_pack_avatar.dart";
|
||||||
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
|
import "package:nexus/widgets/settings/image_usage_chips.dart";
|
||||||
|
import "package:nexus/widgets/settings/pack_image_sheet.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
/// Edits an image pack, or only shows it if [onSave] is null.
|
||||||
|
class const ImagePackSheet({
|
||||||
|
required final String title,
|
||||||
|
required final ImagePackContent content,
|
||||||
|
required final void Function(ImagePackContent content)? onSave,
|
||||||
|
final VoidCallback? onDeleted,
|
||||||
|
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final editable = onSave != null;
|
||||||
|
final nameController = useTextEditingController(
|
||||||
|
text: content.pack?.displayName,
|
||||||
|
);
|
||||||
|
final name = useValueListenable(nameController).text.trim();
|
||||||
|
final avatar = useState(content.pack?.avatarUrl);
|
||||||
|
final usage = useState(content.pack?.usage ?? const ISet.empty());
|
||||||
|
final images = useState(content.images);
|
||||||
|
final uploading = Saving.use();
|
||||||
|
final packUsage = usage.value.isEmpty
|
||||||
|
? ISet(ImageUsage.values)
|
||||||
|
: usage.value;
|
||||||
|
final valid = !uploading.active && name.isNotEmpty;
|
||||||
|
|
||||||
|
Future<void> upload(
|
||||||
|
void Function(String filename, PackImage image) onUploaded,
|
||||||
|
) async {
|
||||||
|
final file = await ref
|
||||||
|
.read(FileDialogHelper.provider)
|
||||||
|
.pickFile(images: true);
|
||||||
|
if (file == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await uploading.run(() async {
|
||||||
|
final content = await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.uploadMedia(.new(path: file.path, encrypt: false));
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (content case ImageMessageContent(:final url, :final info)) {
|
||||||
|
onUploaded(file.name, PackImage(url: url, info: info));
|
||||||
|
} else {
|
||||||
|
throw Exception("The file isn't an image");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void editImage(String shortcode, PackImage image, {bool isNew = false}) =>
|
||||||
|
context.showAdaptiveSheet(
|
||||||
|
(_) => PackImageSheet(
|
||||||
|
title: isNew
|
||||||
|
? "Add Image"
|
||||||
|
: editable
|
||||||
|
? "Edit Image"
|
||||||
|
: ":$shortcode:",
|
||||||
|
shortcode: shortcode,
|
||||||
|
image: image,
|
||||||
|
taken: isNew
|
||||||
|
? images.value.keys.toISet()
|
||||||
|
: images.value.keys.toISet().remove(shortcode),
|
||||||
|
packUsage: packUsage,
|
||||||
|
onSave: editable
|
||||||
|
? (newShortcode, newImage) => images.value = isNew
|
||||||
|
? images.value.add(newShortcode, newImage)
|
||||||
|
// Replaced in place, to keep the images' order
|
||||||
|
: .fromEntries(
|
||||||
|
images.value.entries.map(
|
||||||
|
(entry) => entry.key == shortcode
|
||||||
|
? MapEntry(newShortcode, newImage)
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onRemoved: editable && !isNew
|
||||||
|
? () => images.value = images.value.remove(shortcode)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> delete() async {
|
||||||
|
final confirmed = await context.showAdaptiveSheet<bool>(
|
||||||
|
(context) => SheetLayout(
|
||||||
|
title: Text("Delete Pack"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text("Delete"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onDeleted?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void save() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onSave!(
|
||||||
|
content.copyWith(
|
||||||
|
images: images.value,
|
||||||
|
pack: (content.pack ?? const PackInfo()).copyWith(
|
||||||
|
displayName: name,
|
||||||
|
avatarUrl: avatar.value,
|
||||||
|
usage: usage.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: Text(title),
|
||||||
|
actions: [
|
||||||
|
if (onDeleted != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: uploading.active ? null : delete,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text("Delete"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text(editable ? "Cancel" : "Close"),
|
||||||
|
),
|
||||||
|
if (editable)
|
||||||
|
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
enabled: editable,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: .new(labelText: "Name"),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
enabled: editable && !uploading.active,
|
||||||
|
leading: Icon(Icons.image),
|
||||||
|
title: Text("Avatar"),
|
||||||
|
subtitle: Text("The pack's first image if not set."),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ImagePackAvatar(
|
||||||
|
avatar.value ?? images.value.values.firstOrNull?.url,
|
||||||
|
),
|
||||||
|
if (avatar.value != null && editable)
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Remove avatar",
|
||||||
|
icon: Icon(Icons.delete),
|
||||||
|
onPressed: uploading.active
|
||||||
|
? null
|
||||||
|
: () => avatar.value = null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () =>
|
||||||
|
upload((_, image) => avatar.value = image.url).onError(showError),
|
||||||
|
),
|
||||||
|
Text("Default usage", style: textTheme.titleSmall),
|
||||||
|
ImageUsageChips(
|
||||||
|
value: packUsage,
|
||||||
|
onChanged: editable
|
||||||
|
? (value) =>
|
||||||
|
usage.value = value.length == ImageUsage.values.length
|
||||||
|
? const .empty()
|
||||||
|
: value
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Text("Images", style: textTheme.titleSmall),
|
||||||
|
if (images.value.isEmpty && !editable) Text("No images"),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
...images.value.entries.map(
|
||||||
|
(entry) => Tooltip(
|
||||||
|
message: ":${entry.key}:",
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: .circular(8),
|
||||||
|
onTap: () => editImage(entry.key, entry.value),
|
||||||
|
child: Padding(
|
||||||
|
padding: .all(4),
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: 56,
|
||||||
|
child: MxcEmoji(entry.value.url, label: entry.key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editable)
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: 64,
|
||||||
|
child: uploading.active
|
||||||
|
? Center(child: CircularProgressIndicator())
|
||||||
|
: IconButton(
|
||||||
|
tooltip: "Add image",
|
||||||
|
icon: Icon(Icons.add_photo_alternate),
|
||||||
|
onPressed: () => upload(
|
||||||
|
(filename, image) => editImage(
|
||||||
|
filename
|
||||||
|
.split(".")
|
||||||
|
.first
|
||||||
|
.replaceAll(RegExp(r"[^\w-]"), ""),
|
||||||
|
image,
|
||||||
|
isNew: true,
|
||||||
|
),
|
||||||
|
).onError(showError),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/widgets/settings/image_usage_chips.dart
Normal file
38
lib/widgets/settings/image_usage_chips.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
|
|
||||||
|
/// Chooses what images are used as, keeping at least one usage chosen.
|
||||||
|
class const ImageUsageChips({
|
||||||
|
required final ISet<ImageUsage> value,
|
||||||
|
|
||||||
|
/// Disables the chips if null.
|
||||||
|
required final void Function(ISet<ImageUsage> value)? onChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
static String nameOf(ImageUsage usage) => switch (usage) {
|
||||||
|
.emoticon => "Emoticons",
|
||||||
|
.sticker => "Stickers",
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: ImageUsage.values
|
||||||
|
.map(
|
||||||
|
(usage) => FilterChip(
|
||||||
|
label: Text(nameOf(usage)),
|
||||||
|
selected: value.contains(usage),
|
||||||
|
onSelected:
|
||||||
|
onChanged == null ||
|
||||||
|
(value.length == 1 && value.contains(usage))
|
||||||
|
? null
|
||||||
|
: (selected) => onChanged!(
|
||||||
|
selected ? value.add(usage) : value.remove(usage),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
134
lib/widgets/settings/pack_image_sheet.dart
Normal file
134
lib/widgets/settings/pack_image_sheet.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
|
import "package:nexus/widgets/settings/image_usage_chips.dart";
|
||||||
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
|
/// Edits an image in a pack, or only shows it if [onSave] is null.
|
||||||
|
class const PackImageSheet({
|
||||||
|
required final String title,
|
||||||
|
required final String shortcode,
|
||||||
|
required final PackImage image,
|
||||||
|
|
||||||
|
/// The other images' shortcodes, which this one can't also have.
|
||||||
|
required final ISet<String> taken,
|
||||||
|
|
||||||
|
/// The pack's default usage, which images have unless they set their own.
|
||||||
|
required final ISet<ImageUsage> packUsage,
|
||||||
|
required final void Function(String shortcode, PackImage image)? onSave,
|
||||||
|
final VoidCallback? onRemoved,
|
||||||
|
super.key,
|
||||||
|
}) extends HookWidget {
|
||||||
|
static final shortcodePattern = RegExp(r"^[\w-]+$");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final shortcodeController = useTextEditingController(text: shortcode);
|
||||||
|
final bodyController = useTextEditingController(text: image.body);
|
||||||
|
final usage = useState(image.usage);
|
||||||
|
final newShortcode = useValueListenable(shortcodeController).text.trim();
|
||||||
|
final error = taken.contains(newShortcode)
|
||||||
|
? "Another image has this shortcode"
|
||||||
|
: newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode)
|
||||||
|
? "Only letters, numbers, - and _ are allowed"
|
||||||
|
: null;
|
||||||
|
final valid = newShortcode.isNotEmpty && error == null;
|
||||||
|
|
||||||
|
void save() {
|
||||||
|
final body = bodyController.text.trim();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onSave!(
|
||||||
|
newShortcode,
|
||||||
|
image.copyWith(body: body.isEmpty ? null : body, usage: usage.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: Text(title),
|
||||||
|
actions: [
|
||||||
|
if (onRemoved case final onRemoved?)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onRemoved();
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text("Remove"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Text(onSave == null ? "Close" : "Cancel"),
|
||||||
|
),
|
||||||
|
if (onSave != null)
|
||||||
|
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: 128,
|
||||||
|
child: MxcEmoji(image.url, label: shortcode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: shortcodeController,
|
||||||
|
enabled: onSave != null,
|
||||||
|
onSubmitted: valid ? (_) => save() : null,
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Shortcode",
|
||||||
|
prefixText: ":",
|
||||||
|
suffixText: ":",
|
||||||
|
errorText: error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: bodyController,
|
||||||
|
enabled: onSave != null,
|
||||||
|
onSubmitted: valid ? (_) => save() : null,
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Description",
|
||||||
|
hintText: "Read by screen readers",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .stretch,
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text("Custom usage"),
|
||||||
|
subtitle: Text(
|
||||||
|
usage.value.isEmpty
|
||||||
|
? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})"
|
||||||
|
: "Used as chosen below, instead of the pack's default",
|
||||||
|
),
|
||||||
|
value: usage.value.isNotEmpty,
|
||||||
|
onChanged: onSave == null
|
||||||
|
? null
|
||||||
|
: (custom) =>
|
||||||
|
usage.value = custom ? packUsage : const .empty(),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: Durations.medium2,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
alignment: .topCenter,
|
||||||
|
child: usage.value.isEmpty
|
||||||
|
? const SizedBox(width: .infinity)
|
||||||
|
: Padding(
|
||||||
|
padding: .only(top: 8),
|
||||||
|
child: ImageUsageChips(
|
||||||
|
value: usage.value,
|
||||||
|
onChanged: onSave == null
|
||||||
|
? null
|
||||||
|
: (value) => usage.value = value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue