Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cb983701ca |
|||
|
285e6ad76a |
|||
|
cd60163caf |
|||
|
a2638d5c82 |
|||
|
f971c50df0 |
|||
|
1efbc131b4 |
|||
|
4b329b4c6f |
|||
|
2041b81141 |
|||
|
a3ef4f8135 |
|||
|
0a09a4ee0a |
|||
|
62e200f305 |
|||
|
dd01ad9d74 |
|||
|
853116ab2f |
|||
|
53d9c2b1e6 |
|||
|
401b527714 |
|||
|
3754bc2feb |
|||
|
868023a4a1 |
|||
|
9e6951818a |
|||
|
0743c4cfc6 |
|||
|
6336764da0 |
|||
|
3a39f1c5ac |
|||
|
6e2d56ec75 |
|||
|
4e140e9fc7 |
|||
|
e5dd53ed6c |
|||
|
c0f4c053b0 |
|||
|
ee36a9b1c8 |
|||
|
f5371382d7 |
103 changed files with 3454 additions and 723 deletions
|
|
@ -49,7 +49,7 @@ nexus = {
|
|||
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
|
||||
|
||||
```nix
|
||||
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.1";
|
||||
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0";
|
||||
```
|
||||
|
||||
Then import the module and enable it in your NixOS configuration:
|
||||
|
|
|
|||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
|||
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310
|
||||
Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9
|
||||
|
|
@ -1,7 +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> {
|
||||
|
|
@ -9,13 +12,56 @@ class AccountDataController extends Notifier<AccountData> {
|
|||
AccountData build() => .new();
|
||||
|
||||
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
||||
state = .fromJson({
|
||||
...json.decode(json.encode(state.toJson())),
|
||||
...newAccountData
|
||||
.map((key, value) => MapEntry(key, value["content"]))
|
||||
.unlock,
|
||||
state = newAccountData.entries.fold(state, (data, entry) {
|
||||
try {
|
||||
return .fromJson({
|
||||
...json.decode(json.encode(data.toJson())),
|
||||
entry.key: entry.value["content"],
|
||||
});
|
||||
} catch (error) {
|
||||
debugPrint("Ignoring invalid ${entry.key} account data: $error");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
25
lib/controllers/all_emoji.dart
Normal file
25
lib/controllers/all_emoji.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
||||
|
||||
class AllEmojiController(final String roomId)
|
||||
extends AsyncNotifier<IList<EmojiCategory>> {
|
||||
@override
|
||||
Future<IList<EmojiCategory>> build() async {
|
||||
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
|
||||
final unicode = await ref.watch(EmojiController.provider.future);
|
||||
|
||||
return packs
|
||||
.map((pack) => pack.emojiCategory)
|
||||
.nonNulls
|
||||
.toIList()
|
||||
.addAll(unicode);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<AllEmojiController, IList<EmojiCategory>, String>(
|
||||
AllEmojiController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart";
|
|||
import "package:nexus/models/requests/get_mentions.dart";
|
||||
import "package:nexus/models/requests/get_related_events.dart";
|
||||
import "package:nexus/models/requests/get_room_state.dart";
|
||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
||||
import "package:nexus/models/requests/join_room.dart";
|
||||
import "package:nexus/models/profile_response.dart";
|
||||
|
|
@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier<int> {
|
|||
...json,
|
||||
"content": {
|
||||
...content,
|
||||
"m.relates_to": {
|
||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||
"event_id": request.relatesTo,
|
||||
"rel_type": request.relationType,
|
||||
},
|
||||
if (request.relatesTo != null)
|
||||
"m.relates_to": {
|
||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||
"event_id": request.relatesTo,
|
||||
"rel_type": request.relationType,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -198,14 +200,11 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<String?> setState(SetStateRequest request) async =>
|
||||
await _sendCommand("set_state", request.toJson());
|
||||
|
||||
Future<String?> verify(String recoveryKey) async {
|
||||
try {
|
||||
await _sendCommand("verify", {"recovery_key": recoveryKey});
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
Future<void> pauseSync() => _sendCommand("pause_sync");
|
||||
Future<void> resumeSync() => _sendCommand("resume_sync");
|
||||
|
||||
Future<void> verify(String recoveryKey) =>
|
||||
_sendCommand("verify", {"recovery_key": recoveryKey});
|
||||
|
||||
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
||||
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
||||
|
|
@ -265,6 +264,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<IList<Event>> getSpecificRoomState(
|
||||
GetSpecificRoomStateRequest request,
|
||||
) async => .new(
|
||||
((await _sendCommand("get_specific_room_state", request.toJson()))
|
||||
as List? ??
|
||||
[])
|
||||
.map((event) => .fromJson(event)),
|
||||
);
|
||||
|
||||
Future<IList<Event>?> getRelatedEvents(
|
||||
GetRelatedEventsRequest request,
|
||||
) async {
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -9,11 +9,12 @@ class EventController(final GetEventRequest request)
|
|||
extends AsyncNotifier<Event?> {
|
||||
@override
|
||||
Future<Event?> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[request.roomId]),
|
||||
);
|
||||
final event = room?.events.values.firstWhereOrNull(
|
||||
(event) => event.eventId == request.eventId,
|
||||
final event = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
||||
(event) => event.eventId == request.eventId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (event != null) return event;
|
||||
|
|
|
|||
|
|
@ -156,11 +156,12 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
|||
// Mobile OSes block network access for backgrounded apps, so pause
|
||||
// syncing instead of letting it fail.
|
||||
ref.listen(AppPausedController.provider, (_, paused) {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
if (paused) {
|
||||
GomuksPauseSync(handle);
|
||||
client.pauseSync().onError(showError);
|
||||
} else {
|
||||
ref.invalidate(TypingController.provider);
|
||||
GomuksResumeSync(handle);
|
||||
client.resumeSync().onError(showError);
|
||||
}
|
||||
}, fireImmediately: true);
|
||||
}
|
||||
|
|
|
|||
75
lib/controllers/image_pack_sources.dart
Normal file
75
lib/controllers/image_pack_sources.dart
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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/enabled_image_packs.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
|
||||
typedef ImagePackSource = ({String roomId, String? stateKey});
|
||||
|
||||
class ImagePackSourcesController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePackSource>> {
|
||||
@override
|
||||
Future<IList<ImagePackSource>> build() async {
|
||||
final enabledPacksFuture = ref.watch(
|
||||
EnabledImagePacksController.provider.future,
|
||||
);
|
||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||
final directParents = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) =>
|
||||
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
|
||||
const .empty(),
|
||||
),
|
||||
);
|
||||
|
||||
Iterable<String> parentsOf(String childId) => spaceEdges.entries
|
||||
.where((entry) => entry.value.any((edge) => edge.childId == childId))
|
||||
.map((entry) => entry.key)
|
||||
.followedBy(childId == roomId ? directParents : const []);
|
||||
|
||||
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
|
||||
parentsOf(childId).fold(
|
||||
spaces,
|
||||
(spaces, parentId) => spaces.contains(parentId)
|
||||
? spaces
|
||||
: withAncestors(spaces.add(parentId), parentId),
|
||||
);
|
||||
|
||||
final rooms = ref.read(RoomsController.provider);
|
||||
final spaces = withAncestors(
|
||||
const .empty(),
|
||||
roomId,
|
||||
).remove(roomId).where(rooms.containsKey).toIList();
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final roomsNotifier = ref.read(RoomsController.provider.notifier);
|
||||
|
||||
await Future.wait(
|
||||
spaces
|
||||
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
|
||||
.map(
|
||||
(spaceId) async => roomsNotifier.addState(
|
||||
spaceId,
|
||||
await client.getRoomState(.new(roomId: spaceId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
final enabledPacks = await enabledPacksFuture;
|
||||
|
||||
return .new([
|
||||
(roomId: roomId, stateKey: null),
|
||||
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
|
||||
...enabledPacks.entries.expand(
|
||||
(entry) => entry.value.map(
|
||||
(stateKey) => (roomId: entry.key, stateKey: stateKey),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
|
||||
ImagePackSourcesController.new,
|
||||
);
|
||||
}
|
||||
43
lib/controllers/image_packs.dart
Normal file
43
lib/controllers/image_packs.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
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/helpers/extensions/room_image_packs.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
|
||||
class ImagePacksController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePack>> {
|
||||
@override
|
||||
Future<IList<ImagePack>> build() async {
|
||||
final sources = await ref.watch(
|
||||
ImagePackSourcesController.provider(roomId).future,
|
||||
);
|
||||
|
||||
final packs = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => sources.expand((source) {
|
||||
final packs =
|
||||
rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty();
|
||||
|
||||
return switch (source.stateKey) {
|
||||
final stateKey? => [?packs[stateKey]],
|
||||
null => packs.values,
|
||||
};
|
||||
}).toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return .new(
|
||||
packs
|
||||
.whereNoDuplicates(by: (pack) => pack.id)
|
||||
.where(
|
||||
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePacksController, IList<ImagePack>, String>(
|
||||
ImagePacksController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ class NotificationController
|
|||
|
||||
if (Platform.isLinux) {
|
||||
final portal = await ref.read(PortalController.provider.future);
|
||||
const escape = HtmlEscape(HtmlEscapeMode.element);
|
||||
const escape = HtmlEscape(.element);
|
||||
|
||||
await portal.notification.addNotification(
|
||||
id.toString(),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
|
|||
),
|
||||
);
|
||||
|
||||
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
|
||||
return .new(
|
||||
recents.where(
|
||||
(recent) =>
|
||||
// Is unicode or custom emote
|
||||
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> add(String emoji) => ref
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart";
|
|||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/reaction.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/direction.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
|
|
@ -241,17 +243,14 @@ class RoomChatController(final ChatParams info)
|
|||
ref.invalidate(provider);
|
||||
}
|
||||
|
||||
var taggedMessage = text;
|
||||
|
||||
for (final tag in tags) {
|
||||
final escaped = RegExp.escape(tag.id);
|
||||
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
|
||||
|
||||
taggedMessage = taggedMessage.replaceAllMapped(
|
||||
pattern,
|
||||
// Replaces each formatted tag, like "@id#name#", with its ID
|
||||
final taggedMessage = tags.fold(
|
||||
text,
|
||||
(message, tag) => message.replaceAllMapped(
|
||||
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
|
||||
(match) => match.group(1)!,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final event = await client.sendMessage(
|
||||
|
|
@ -315,14 +314,24 @@ class RoomChatController(final ChatParams info)
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> sendReaction(String reaction, Event event) async {
|
||||
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
|
||||
/// with their [shortcode].
|
||||
Future<void> sendReaction(
|
||||
String reaction,
|
||||
Event event, {
|
||||
String? shortcode,
|
||||
}) async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
await client.sendEvent(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
type: EventType.reaction.type,
|
||||
content: ReactionContent(key: reaction),
|
||||
// Wrapped in colons, like other clients send it
|
||||
content: ReactionContent(
|
||||
key: reaction,
|
||||
shortcode: shortcode == null ? null : ":$shortcode:",
|
||||
),
|
||||
synchronous: true,
|
||||
disableEncryption: true,
|
||||
relatesTo: event.eventId,
|
||||
|
|
@ -331,6 +340,33 @@ class RoomChatController(final ChatParams info)
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> sendSticker(String shortcode, PackImage image) async {
|
||||
final event = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.sendEvent(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
type: EventType.sticker.type,
|
||||
content: StickerContent(
|
||||
body: image.body ?? shortcode,
|
||||
info: image.info ?? const .new(),
|
||||
url: image.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: .new(
|
||||
events: .new({event.rowId: event}),
|
||||
clientSticky: .new({event.rowId}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||
RoomChatController.new,
|
||||
|
|
|
|||
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,10 +7,12 @@ 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";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/join_rule_name.dart";
|
||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
|
@ -20,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";
|
||||
|
|
@ -27,14 +30,17 @@ import "package:nexus/models/content/name.dart";
|
|||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/join_rule.dart";
|
||||
import "package:nexus/models/power_level_permission.dart";
|
||||
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";
|
||||
|
|
@ -89,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));
|
||||
|
|
@ -156,20 +163,81 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
|
||||
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them
|
||||
final messageType = stateContent(.encryption) is EncryptionContent
|
||||
? EventType.encrypted
|
||||
: EventType.message;
|
||||
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,
|
||||
|
|
@ -218,12 +286,10 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
.run(() => setState(.powerLevels, content).onError(showError));
|
||||
|
||||
// Event types with their own level that don't have a setting of their own
|
||||
final listedEvents = [
|
||||
messageType,
|
||||
EventType.sticker,
|
||||
EventType.reaction,
|
||||
EventType.pinnedEvents,
|
||||
].map((type) => type.type).toISet();
|
||||
final listedEvents = shownEvents
|
||||
.addAll(PowerLevelGroup.values.expand((group) => group.events))
|
||||
.map((event) => event.type.type)
|
||||
.toISet();
|
||||
final otherEvents = powerLevels.events.entries
|
||||
.where((entry) => !listedEvents.contains(entry.key))
|
||||
.toIList()
|
||||
|
|
@ -288,20 +354,94 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
),
|
||||
);
|
||||
|
||||
/// A setting for the level needed to send [type], which otherwise needs [fallback].
|
||||
Setting eventSetting(
|
||||
EventType type, {
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int fallback,
|
||||
}) => powerLevelSetting(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
value: powerLevels.events[type.type] ?? fallback,
|
||||
update: (level) => powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type.type, level),
|
||||
Setting permissionSetting(PowerLevelPermission permission) =>
|
||||
powerLevelSetting(
|
||||
title: permission.title,
|
||||
description: permission.description,
|
||||
icon: permission.icon,
|
||||
value: permission.levelIn(powerLevels),
|
||||
update: (level) => permission.withLevel(powerLevels, level),
|
||||
);
|
||||
|
||||
/// A setting for the level needed to send [event], which otherwise needs [fallback].
|
||||
Setting eventSetting(PowerLevelEvent event, {required int fallback}) =>
|
||||
powerLevelSetting(
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
icon: event.icon,
|
||||
value: powerLevels.events[event.type.type] ?? fallback,
|
||||
update: (level) => powerLevels.copyWith(
|
||||
events: powerLevels.events.add(event.type.type, level),
|
||||
),
|
||||
);
|
||||
|
||||
Widget tileOf(Setting setting) =>
|
||||
setting.builder(setting.title, setting.description, setting.icon);
|
||||
|
||||
/// A setting for all of [group]'s levels at once, which expands to set each.
|
||||
Setting groupSetting(PowerLevelGroup group) => .new(
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
icon: group.icon,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final expanded = useState(false);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: group.levelIn(powerLevels),
|
||||
ownLevel: ownLevel,
|
||||
// Changing it would change levels above the user's own
|
||||
onChanged:
|
||||
canSetPowerLevels &&
|
||||
group.highestIn(powerLevels) <= ownLevel
|
||||
? (level) =>
|
||||
setPowerLevels(group.withLevel(powerLevels, level))
|
||||
: null,
|
||||
trailing: IconButton(
|
||||
tooltip: expanded.value ? "Collapse" : "Set each",
|
||||
icon: AnimatedRotation(
|
||||
turns: expanded.value ? 0.5 : 0,
|
||||
duration: Durations.medium2,
|
||||
child: Icon(Icons.expand_more),
|
||||
),
|
||||
onPressed: () => expanded.value = !expanded.value,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: expanded.value
|
||||
? Padding(
|
||||
padding: .only(left: 16),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
...group.permissions
|
||||
.map(permissionSetting)
|
||||
.map(tileOf),
|
||||
...group.events
|
||||
.map(
|
||||
(event) => eventSetting(
|
||||
event,
|
||||
fallback: powerLevels.stateDefault,
|
||||
),
|
||||
)
|
||||
.map(tileOf),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: .infinity),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -431,14 +571,7 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
subtitle: Text(description),
|
||||
value: joinRules.joinRule,
|
||||
options: joinRuleOptions,
|
||||
getName: (rule) => switch (rule) {
|
||||
.public => "Public",
|
||||
.invite => "Invite only",
|
||||
.knock => "Knock",
|
||||
.restricted => "Restricted",
|
||||
.knockRestricted => "Restricted or knock",
|
||||
.private => "Private",
|
||||
},
|
||||
getName: (rule) => rule.displayName,
|
||||
onChanged: canSetJoinRules
|
||||
? (rule) => setState(
|
||||
.joinRules,
|
||||
|
|
@ -598,6 +731,7 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
.invited => "After invite",
|
||||
.joined => "After join",
|
||||
.worldReadable => "Anyone",
|
||||
.unknown => "Unknown",
|
||||
},
|
||||
onChanged: canSetHistoryVisibility
|
||||
? (visibility) => setState(
|
||||
|
|
@ -615,13 +749,7 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
title: "Permissions",
|
||||
icon: Icons.admin_panel_settings,
|
||||
settings: .new([
|
||||
powerLevelSetting(
|
||||
title: "Default Power Level",
|
||||
description: "The power level of users who aren't given one.",
|
||||
icon: Icons.person,
|
||||
value: powerLevels.usersDefault,
|
||||
update: (level) => powerLevels.copyWith(usersDefault: level),
|
||||
),
|
||||
permissionSetting(.usersDefault),
|
||||
header("Users", "Power levels of specific users.", Icons.group),
|
||||
...users.map((entry) {
|
||||
final member = memberOf(entry.key);
|
||||
|
|
@ -682,87 +810,31 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
),
|
||||
),
|
||||
),
|
||||
header(
|
||||
"Groups",
|
||||
"Power levels set together, which can each be set on their own.",
|
||||
Icons.workspaces,
|
||||
),
|
||||
...PowerLevelGroup.values.map(groupSetting),
|
||||
header(
|
||||
"Regular Events",
|
||||
"Power levels needed to send messages and other timeline events.",
|
||||
Icons.chat,
|
||||
),
|
||||
eventSetting(
|
||||
messageType,
|
||||
title: "Send Messages",
|
||||
description: "The power level needed to send messages.",
|
||||
icon: Icons.message,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.sticker,
|
||||
title: "Send Stickers",
|
||||
description: "The power level needed to send stickers.",
|
||||
icon: Icons.sticky_note_2,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.reaction,
|
||||
title: "Send Reactions",
|
||||
description: "The power level needed to react to messages.",
|
||||
icon: Icons.add_reaction,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for Events",
|
||||
description:
|
||||
"The power level needed to send events without their own.",
|
||||
icon: Icons.more_horiz,
|
||||
value: powerLevels.eventsDefault,
|
||||
update: (level) => powerLevels.copyWith(eventsDefault: level),
|
||||
),
|
||||
...shownEvents
|
||||
.where((event) => event != .pinnedEvents)
|
||||
.map(
|
||||
(event) =>
|
||||
eventSetting(event, fallback: powerLevels.eventsDefault),
|
||||
),
|
||||
permissionSetting(.eventsDefault),
|
||||
header(
|
||||
"State Events",
|
||||
"Power levels needed to manage members and change the room's state.",
|
||||
"Power levels needed to change the room's state.",
|
||||
Icons.settings,
|
||||
),
|
||||
eventSetting(
|
||||
.pinnedEvents,
|
||||
title: "Pin Messages",
|
||||
description: "The power level needed to pin messages.",
|
||||
icon: Icons.push_pin,
|
||||
fallback: powerLevels.stateDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Invite Users",
|
||||
description: "The power level needed to invite users.",
|
||||
icon: Icons.person_add,
|
||||
value: powerLevels.invite,
|
||||
update: (level) => powerLevels.copyWith(invite: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Kick Users",
|
||||
description: "The power level needed to kick users.",
|
||||
icon: Icons.sports_martial_arts,
|
||||
value: powerLevels.kick,
|
||||
update: (level) => powerLevels.copyWith(kick: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Ban Users",
|
||||
description: "The power level needed to ban and unban users.",
|
||||
icon: Icons.gavel,
|
||||
value: powerLevels.ban,
|
||||
update: (level) => powerLevels.copyWith(ban: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Delete Others' Messages",
|
||||
description: "The power level needed to remove others' messages.",
|
||||
icon: Icons.delete,
|
||||
value: powerLevels.redact,
|
||||
update: (level) => powerLevels.copyWith(redact: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for State Events",
|
||||
description: "The power level needed to send state events without their own.",
|
||||
icon: Icons.tune,
|
||||
value: powerLevels.stateDefault,
|
||||
update: (level) => powerLevels.copyWith(stateDefault: level),
|
||||
),
|
||||
permissionSetting(.invite),
|
||||
permissionSetting(.stateDefault),
|
||||
header(
|
||||
"Other Events",
|
||||
"Power levels for specific event types.",
|
||||
|
|
@ -813,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,29 +11,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
@override
|
||||
IMap<String, Room> build() => .new();
|
||||
|
||||
void addState(
|
||||
String roomId,
|
||||
IList<Event> state, {
|
||||
bool isMembers = false,
|
||||
}) => update(
|
||||
.new({
|
||||
roomId: Room(
|
||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||
hasFetchedState: true,
|
||||
hasFetchedMembers: isMembers,
|
||||
state: .new(
|
||||
state
|
||||
.groupListsBy((event) => event.type)
|
||||
.map(
|
||||
(type, group) => .new(
|
||||
type,
|
||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
.new(),
|
||||
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||
update(
|
||||
.new({
|
||||
roomId: stateRoom(state)
|
||||
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
|
||||
void addSpecificState(Iterable<Event> state) => update(
|
||||
.new(
|
||||
state
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
||||
),
|
||||
);
|
||||
|
||||
static Room stateRoom(Iterable<Event> state) => Room(
|
||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||
state: .new(
|
||||
state
|
||||
.groupListsBy((event) => event.type)
|
||||
.map(
|
||||
(type, group) => .new(
|
||||
type,
|
||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||
|
|
|
|||
|
|
@ -9,22 +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";
|
||||
|
|
@ -35,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,
|
||||
|
|
@ -104,6 +140,26 @@ class SettingsSectionsController
|
|||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Name Colors",
|
||||
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
|
||||
icon: Icons.format_color_text,
|
||||
builder: (title, description, icon) => DialogListTile<NameColor>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: settings.nameColor,
|
||||
options: NameColor.values,
|
||||
getName: (option) => switch (option) {
|
||||
.powerLevel => "By power level",
|
||||
.user => "By user",
|
||||
},
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(nameColor: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
|
|
@ -209,6 +265,21 @@ class SettingsSectionsController
|
|||
.onError(showError),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Hide Server ACL Events",
|
||||
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
|
||||
icon: Icons.dns,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.hideServerAclEvents,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(hideServerAclEvents: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
|
|
@ -264,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,
|
||||
|
|
|
|||
|
|
@ -25,55 +25,50 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
|||
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
||||
);
|
||||
|
||||
Set<String> collectDescendants(String startId) {
|
||||
final visited = <String>{};
|
||||
final stack = [startId];
|
||||
|
||||
while (stack.isNotEmpty) {
|
||||
final current = stack.removeLast();
|
||||
final children = childrenById[current] ?? const [];
|
||||
|
||||
for (final child in children) {
|
||||
if (visited.add(child)) {
|
||||
stack.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
/// Everything under [spaceId], however deep, skipping any already
|
||||
/// [visited] so cycles end.
|
||||
ISet<String> collectDescendants(
|
||||
String spaceId, [
|
||||
ISet<String> visited = const .empty(),
|
||||
]) => (childrenById[spaceId] ?? const []).fold(
|
||||
visited,
|
||||
(visited, childId) => visited.contains(childId)
|
||||
? visited
|
||||
: collectDescendants(childId, visited.add(childId)),
|
||||
);
|
||||
|
||||
Space buildSpace(String spaceId) {
|
||||
final space = rooms[spaceId];
|
||||
final directChildrenIds = childrenById[spaceId] ?? const [];
|
||||
|
||||
final directRooms = <Room>[];
|
||||
final subSpaces = <Subspace>[];
|
||||
|
||||
for (final childId in directChildrenIds) {
|
||||
final room = rooms[childId];
|
||||
if (room == null) continue;
|
||||
|
||||
if (childrenById.containsKey(childId)) {
|
||||
final descendants = collectDescendants(childId);
|
||||
|
||||
subSpaces.add(
|
||||
.new(
|
||||
room: room,
|
||||
children: .new(descendants.map((id) => rooms[id]).nonNulls),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
directRooms.add(room);
|
||||
}
|
||||
}
|
||||
|
||||
return .new(
|
||||
id: spaceId,
|
||||
room: space,
|
||||
title: space?.metadata?.name ?? "Unnamed Space",
|
||||
children: .new(directRooms),
|
||||
subSpaces: .new(subSpaces),
|
||||
children: .new(
|
||||
directChildrenIds
|
||||
.where((childId) => !childrenById.containsKey(childId))
|
||||
.map((childId) => rooms[childId])
|
||||
.nonNulls,
|
||||
),
|
||||
subSpaces: .new(
|
||||
directChildrenIds
|
||||
.where(childrenById.containsKey)
|
||||
.map(
|
||||
(childId) => switch (rooms[childId]) {
|
||||
final room? => Subspace(
|
||||
room: room,
|
||||
children: .new(
|
||||
collectDescendants(childId)
|
||||
.map((id) => rooms[id])
|
||||
.nonNulls,
|
||||
),
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +1,48 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class ViaController(final Room room) extends Notifier<String> {
|
||||
@override
|
||||
String build() {
|
||||
final servers = <String>{};
|
||||
final powerLevelUsers =
|
||||
switch (room.state[EventType.powerLevels.type]?[""]) {
|
||||
final rowId? => switch (room.events[rowId]?.content) {
|
||||
PowerLevelsContent(:final users) => users.keys.toIList(),
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
} ??
|
||||
.new([]);
|
||||
|
||||
void addUserId(String? userId) {
|
||||
final server = userId?.split(":").lastOrNull;
|
||||
if (server != null) {
|
||||
servers.add(server);
|
||||
}
|
||||
}
|
||||
final joinedMembers =
|
||||
(room.state[EventType.membership.type]?.values ?? const [])
|
||||
.map(
|
||||
(rowId) => switch (room.events[rowId]) {
|
||||
Event(
|
||||
stateKey: final userId?,
|
||||
content: MembershipContent(status: .join),
|
||||
) =>
|
||||
userId,
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.toIList();
|
||||
|
||||
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
||||
|
||||
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
||||
final powerLevels = powerLevelsEventId == null
|
||||
? null
|
||||
: room.events[powerLevelsEventId];
|
||||
|
||||
if (powerLevels?.content case PowerLevelsContent(:final users)) {
|
||||
for (final userId in users.keys) {
|
||||
addUserId(userId);
|
||||
if (servers.length >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
final members = room.state[EventType.membership.type]?.values.toIList();
|
||||
for (var i = 0; servers.length < 5; i++) {
|
||||
final membershipEventId = members?.getOrNull(i);
|
||||
final member = membershipEventId == null
|
||||
? null
|
||||
: room.events[membershipEventId];
|
||||
|
||||
if (member?.content case MembershipContent(:final status)) {
|
||||
if (status == .join) {
|
||||
addUserId(member?.stateKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (members?.getOrNull(i) == null) break;
|
||||
}
|
||||
final servers = [?ref.watch(ClientStateController.provider)?.userId]
|
||||
.toIList()
|
||||
.addAll(powerLevelUsers)
|
||||
.addAll(joinedMembers)
|
||||
.map((userId) => userId.substring(userId.indexOf(":") + 1))
|
||||
.whereNoDuplicates()
|
||||
.take(5)
|
||||
.toISet();
|
||||
|
||||
return servers.isEmpty
|
||||
? ""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import "package:nexus/models/content/message.dart";
|
|||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/emoji_picker.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
extension BuildEventOptions on Event {
|
||||
|
|
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
|
|||
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
||||
children: [
|
||||
...{
|
||||
// Custom emoji are sent without their shortcode, as only
|
||||
// their mxc URL is recorded
|
||||
...ref
|
||||
.read(RecentEmojiController.provider)
|
||||
.map((entry) => entry.emoji),
|
||||
|
|
@ -109,10 +112,15 @@ extension BuildEventOptions on Event {
|
|||
.take(isDesktop ? 3 : 5)
|
||||
.map(
|
||||
(emoji) => IconButton(
|
||||
icon: Text(
|
||||
emoji,
|
||||
style: .new(fontSize: isDesktop ? null : 18),
|
||||
),
|
||||
icon: emoji.startsWith("mxc://")
|
||||
? SizedBox.square(
|
||||
dimension: isDesktop ? 18 : 24,
|
||||
child: MxcEmoji(Uri.parse(emoji)),
|
||||
)
|
||||
: Text(
|
||||
emoji,
|
||||
style: .new(fontSize: isDesktop ? null : 18),
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref
|
||||
|
|
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
|
|||
|
||||
context.showAdaptiveSheet(
|
||||
(context) => EmojiPicker(
|
||||
roomId: roomId,
|
||||
allowFreeText: true,
|
||||
onSelection: (emoji) {
|
||||
onSelection: (emoji, {shortcode, description}) {
|
||||
Navigator.of(context).pop();
|
||||
ref
|
||||
.read(notifier)
|
||||
.sendReaction(emoji, this)
|
||||
.sendReaction(emoji, this, shortcode: shortcode)
|
||||
.onError(showError);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue