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:
|
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
|
||||||
|
|
||||||
```nix
|
```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:
|
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 "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_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> {
|
||||||
|
|
@ -9,12 +12,55 @@ class AccountDataController extends Notifier<AccountData> {
|
||||||
AccountData build() => .new();
|
AccountData build() => .new();
|
||||||
|
|
||||||
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
|
||||||
state = .fromJson({
|
state = newAccountData.entries.fold(state, (data, entry) {
|
||||||
...json.decode(json.encode(state.toJson())),
|
try {
|
||||||
...newAccountData
|
return .fromJson({
|
||||||
.map((key, value) => MapEntry(key, value["content"]))
|
...json.decode(json.encode(data.toJson())),
|
||||||
.unlock,
|
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>(
|
static final provider = NotifierProvider<AccountDataController, AccountData>(
|
||||||
AccountDataController.new,
|
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_mentions.dart";
|
||||||
import "package:nexus/models/requests/get_related_events.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_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/get_space_hierarchy.dart";
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.dart";
|
import "package:nexus/models/profile_response.dart";
|
||||||
|
|
@ -185,6 +186,7 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
...json,
|
...json,
|
||||||
"content": {
|
"content": {
|
||||||
...content,
|
...content,
|
||||||
|
if (request.relatesTo != null)
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||||
"event_id": request.relatesTo,
|
"event_id": request.relatesTo,
|
||||||
|
|
@ -198,14 +200,11 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<String?> setState(SetStateRequest request) async =>
|
Future<String?> setState(SetStateRequest request) async =>
|
||||||
await _sendCommand("set_state", request.toJson());
|
await _sendCommand("set_state", request.toJson());
|
||||||
|
|
||||||
Future<String?> verify(String recoveryKey) async {
|
Future<void> pauseSync() => _sendCommand("pause_sync");
|
||||||
try {
|
Future<void> resumeSync() => _sendCommand("resume_sync");
|
||||||
await _sendCommand("verify", {"recovery_key": recoveryKey});
|
|
||||||
return null;
|
Future<void> verify(String recoveryKey) =>
|
||||||
} catch (error) {
|
_sendCommand("verify", {"recovery_key": recoveryKey});
|
||||||
return error.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
|
||||||
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
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(
|
Future<IList<Event>?> getRelatedEvents(
|
||||||
GetRelatedEventsRequest request,
|
GetRelatedEventsRequest request,
|
||||||
) async {
|
) 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?> {
|
extends AsyncNotifier<Event?> {
|
||||||
@override
|
@override
|
||||||
Future<Event?> build() async {
|
Future<Event?> build() async {
|
||||||
final room = ref.watch(
|
final event = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[request.roomId]),
|
RoomsController.provider.select(
|
||||||
);
|
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
|
||||||
final event = room?.events.values.firstWhereOrNull(
|
|
||||||
(event) => event.eventId == request.eventId,
|
(event) => event.eventId == request.eventId,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (event != null) return event;
|
if (event != null) return event;
|
||||||
|
|
|
||||||
|
|
@ -156,11 +156,12 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
// Mobile OSes block network access for backgrounded apps, so pause
|
// Mobile OSes block network access for backgrounded apps, so pause
|
||||||
// syncing instead of letting it fail.
|
// syncing instead of letting it fail.
|
||||||
ref.listen(AppPausedController.provider, (_, paused) {
|
ref.listen(AppPausedController.provider, (_, paused) {
|
||||||
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
if (paused) {
|
if (paused) {
|
||||||
GomuksPauseSync(handle);
|
client.pauseSync().onError(showError);
|
||||||
} else {
|
} else {
|
||||||
ref.invalidate(TypingController.provider);
|
ref.invalidate(TypingController.provider);
|
||||||
GomuksResumeSync(handle);
|
client.resumeSync().onError(showError);
|
||||||
}
|
}
|
||||||
}, fireImmediately: true);
|
}, 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) {
|
if (Platform.isLinux) {
|
||||||
final portal = await ref.read(PortalController.provider.future);
|
final portal = await ref.read(PortalController.provider.future);
|
||||||
const escape = HtmlEscape(HtmlEscapeMode.element);
|
const escape = HtmlEscape(.element);
|
||||||
|
|
||||||
await portal.notification.addNotification(
|
await portal.notification.addNotification(
|
||||||
id.toString(),
|
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
|
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/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/content/content.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/reaction.dart";
|
||||||
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/direction.dart";
|
import "package:nexus/models/direction.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/redact_event.dart";
|
import "package:nexus/models/requests/redact_event.dart";
|
||||||
|
|
@ -241,17 +243,14 @@ class RoomChatController(final ChatParams info)
|
||||||
ref.invalidate(provider);
|
ref.invalidate(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
var taggedMessage = text;
|
// Replaces each formatted tag, like "@id#name#", with its ID
|
||||||
|
final taggedMessage = tags.fold(
|
||||||
for (final tag in tags) {
|
text,
|
||||||
final escaped = RegExp.escape(tag.id);
|
(message, tag) => message.replaceAllMapped(
|
||||||
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
|
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
|
||||||
|
|
||||||
taggedMessage = taggedMessage.replaceAllMapped(
|
|
||||||
pattern,
|
|
||||||
(match) => match.group(1)!,
|
(match) => match.group(1)!,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
final event = await client.sendMessage(
|
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);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: info.roomId,
|
||||||
type: EventType.reaction.type,
|
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,
|
synchronous: true,
|
||||||
disableEncryption: true,
|
disableEncryption: true,
|
||||||
relatesTo: event.eventId,
|
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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||||
RoomChatController.new,
|
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/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";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/file_dialog_helper.dart";
|
||||||
import "package:nexus/helpers/hooks/saving.dart";
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
import "package:nexus/main.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/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";
|
||||||
|
|
@ -27,14 +30,17 @@ import "package:nexus/models/content/name.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/content/topic.dart";
|
import "package:nexus/models/content/topic.dart";
|
||||||
import "package:nexus/models/join_rule.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/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";
|
||||||
|
|
@ -89,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));
|
||||||
|
|
@ -156,20 +163,81 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
ClientStateController.provider.select((state) => state?.userId),
|
ClientStateController.provider.select((state) => state?.userId),
|
||||||
);
|
);
|
||||||
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(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 encrypted = stateContent(.encryption) is EncryptionContent;
|
||||||
final messageType = stateContent(.encryption) is EncryptionContent
|
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
|
||||||
? EventType.encrypted
|
|
||||||
: EventType.message;
|
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -218,12 +286,10 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
.run(() => setState(.powerLevels, content).onError(showError));
|
.run(() => setState(.powerLevels, content).onError(showError));
|
||||||
|
|
||||||
// Event types with their own level that don't have a setting of their own
|
// Event types with their own level that don't have a setting of their own
|
||||||
final listedEvents = [
|
final listedEvents = shownEvents
|
||||||
messageType,
|
.addAll(PowerLevelGroup.values.expand((group) => group.events))
|
||||||
EventType.sticker,
|
.map((event) => event.type.type)
|
||||||
EventType.reaction,
|
.toISet();
|
||||||
EventType.pinnedEvents,
|
|
||||||
].map((type) => type.type).toISet();
|
|
||||||
final otherEvents = powerLevels.events.entries
|
final otherEvents = powerLevels.events.entries
|
||||||
.where((entry) => !listedEvents.contains(entry.key))
|
.where((entry) => !listedEvents.contains(entry.key))
|
||||||
.toIList()
|
.toIList()
|
||||||
|
|
@ -288,20 +354,94 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// A setting for the level needed to send [type], which otherwise needs [fallback].
|
Setting permissionSetting(PowerLevelPermission permission) =>
|
||||||
Setting eventSetting(
|
powerLevelSetting(
|
||||||
EventType type, {
|
title: permission.title,
|
||||||
required String title,
|
description: permission.description,
|
||||||
required String description,
|
icon: permission.icon,
|
||||||
required IconData icon,
|
value: permission.levelIn(powerLevels),
|
||||||
required int fallback,
|
update: (level) => permission.withLevel(powerLevels, level),
|
||||||
}) => powerLevelSetting(
|
);
|
||||||
|
|
||||||
|
/// 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,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
icon: icon,
|
value: group.levelIn(powerLevels),
|
||||||
value: powerLevels.events[type.type] ?? fallback,
|
ownLevel: ownLevel,
|
||||||
update: (level) => powerLevels.copyWith(
|
// Changing it would change levels above the user's own
|
||||||
events: powerLevels.events.add(type.type, level),
|
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),
|
subtitle: Text(description),
|
||||||
value: joinRules.joinRule,
|
value: joinRules.joinRule,
|
||||||
options: joinRuleOptions,
|
options: joinRuleOptions,
|
||||||
getName: (rule) => switch (rule) {
|
getName: (rule) => rule.displayName,
|
||||||
.public => "Public",
|
|
||||||
.invite => "Invite only",
|
|
||||||
.knock => "Knock",
|
|
||||||
.restricted => "Restricted",
|
|
||||||
.knockRestricted => "Restricted or knock",
|
|
||||||
.private => "Private",
|
|
||||||
},
|
|
||||||
onChanged: canSetJoinRules
|
onChanged: canSetJoinRules
|
||||||
? (rule) => setState(
|
? (rule) => setState(
|
||||||
.joinRules,
|
.joinRules,
|
||||||
|
|
@ -598,6 +731,7 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
.invited => "After invite",
|
.invited => "After invite",
|
||||||
.joined => "After join",
|
.joined => "After join",
|
||||||
.worldReadable => "Anyone",
|
.worldReadable => "Anyone",
|
||||||
|
.unknown => "Unknown",
|
||||||
},
|
},
|
||||||
onChanged: canSetHistoryVisibility
|
onChanged: canSetHistoryVisibility
|
||||||
? (visibility) => setState(
|
? (visibility) => setState(
|
||||||
|
|
@ -615,13 +749,7 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
title: "Permissions",
|
title: "Permissions",
|
||||||
icon: Icons.admin_panel_settings,
|
icon: Icons.admin_panel_settings,
|
||||||
settings: .new([
|
settings: .new([
|
||||||
powerLevelSetting(
|
permissionSetting(.usersDefault),
|
||||||
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),
|
|
||||||
),
|
|
||||||
header("Users", "Power levels of specific users.", Icons.group),
|
header("Users", "Power levels of specific users.", Icons.group),
|
||||||
...users.map((entry) {
|
...users.map((entry) {
|
||||||
final member = memberOf(entry.key);
|
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(
|
header(
|
||||||
"Regular Events",
|
"Regular Events",
|
||||||
"Power levels needed to send messages and other timeline events.",
|
"Power levels needed to send messages and other timeline events.",
|
||||||
Icons.chat,
|
Icons.chat,
|
||||||
),
|
),
|
||||||
eventSetting(
|
...shownEvents
|
||||||
messageType,
|
.where((event) => event != .pinnedEvents)
|
||||||
title: "Send Messages",
|
.map(
|
||||||
description: "The power level needed to send messages.",
|
(event) =>
|
||||||
icon: Icons.message,
|
eventSetting(event, fallback: powerLevels.eventsDefault),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
permissionSetting(.eventsDefault),
|
||||||
header(
|
header(
|
||||||
"State Events",
|
"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,
|
Icons.settings,
|
||||||
),
|
),
|
||||||
eventSetting(
|
permissionSetting(.invite),
|
||||||
.pinnedEvents,
|
permissionSetting(.stateDefault),
|
||||||
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),
|
|
||||||
),
|
|
||||||
header(
|
header(
|
||||||
"Other Events",
|
"Other Events",
|
||||||
"Power levels for specific event types.",
|
"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,16 +11,25 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => .new();
|
IMap<String, Room> build() => .new();
|
||||||
|
|
||||||
void addState(
|
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||||
String roomId,
|
update(
|
||||||
IList<Event> state, {
|
|
||||||
bool isMembers = false,
|
|
||||||
}) => update(
|
|
||||||
.new({
|
.new({
|
||||||
roomId: Room(
|
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))),
|
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||||
hasFetchedState: true,
|
|
||||||
hasFetchedMembers: isMembers,
|
|
||||||
state: .new(
|
state: .new(
|
||||||
state
|
state
|
||||||
.groupListsBy((event) => event.type)
|
.groupListsBy((event) => event.type)
|
||||||
|
|
@ -31,9 +40,6 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
}),
|
|
||||||
.new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
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/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/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";
|
||||||
|
|
@ -35,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,
|
||||||
|
|
@ -104,6 +140,26 @@ class SettingsSectionsController
|
||||||
: null,
|
: 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(
|
.new(
|
||||||
|
|
@ -209,6 +265,21 @@ class SettingsSectionsController
|
||||||
.onError(showError),
|
.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(
|
.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(
|
.new(
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
icon: Icons.notifications,
|
icon: Icons.notifications,
|
||||||
|
|
|
||||||
|
|
@ -25,55 +25,50 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
|
||||||
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
|
||||||
);
|
);
|
||||||
|
|
||||||
Set<String> collectDescendants(String startId) {
|
/// Everything under [spaceId], however deep, skipping any already
|
||||||
final visited = <String>{};
|
/// [visited] so cycles end.
|
||||||
final stack = [startId];
|
ISet<String> collectDescendants(
|
||||||
|
String spaceId, [
|
||||||
while (stack.isNotEmpty) {
|
ISet<String> visited = const .empty(),
|
||||||
final current = stack.removeLast();
|
]) => (childrenById[spaceId] ?? const []).fold(
|
||||||
final children = childrenById[current] ?? const [];
|
visited,
|
||||||
|
(visited, childId) => visited.contains(childId)
|
||||||
for (final child in children) {
|
? visited
|
||||||
if (visited.add(child)) {
|
: collectDescendants(childId, visited.add(childId)),
|
||||||
stack.add(child);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return visited;
|
|
||||||
}
|
|
||||||
|
|
||||||
Space buildSpace(String spaceId) {
|
Space buildSpace(String spaceId) {
|
||||||
final space = rooms[spaceId];
|
final space = rooms[spaceId];
|
||||||
final directChildrenIds = childrenById[spaceId] ?? const [];
|
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(
|
return .new(
|
||||||
id: spaceId,
|
id: spaceId,
|
||||||
room: space,
|
room: space,
|
||||||
title: space?.metadata?.name ?? "Unnamed Space",
|
title: space?.metadata?.name ?? "Unnamed Space",
|
||||||
children: .new(directRooms),
|
children: .new(
|
||||||
subSpaces: .new(subSpaces),
|
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: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/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class ViaController(final Room room) extends Notifier<String> {
|
class ViaController(final Room room) extends Notifier<String> {
|
||||||
@override
|
@override
|
||||||
String build() {
|
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 joinedMembers =
|
||||||
final server = userId?.split(":").lastOrNull;
|
(room.state[EventType.membership.type]?.values ?? const [])
|
||||||
if (server != null) {
|
.map(
|
||||||
servers.add(server);
|
(rowId) => switch (room.events[rowId]) {
|
||||||
}
|
Event(
|
||||||
}
|
stateKey: final userId?,
|
||||||
|
content: MembershipContent(status: .join),
|
||||||
|
) =>
|
||||||
|
userId,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.nonNulls
|
||||||
|
.toIList();
|
||||||
|
|
||||||
addUserId(ref.watch(ClientStateController.provider)?.userId);
|
final servers = [?ref.watch(ClientStateController.provider)?.userId]
|
||||||
|
.toIList()
|
||||||
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
|
.addAll(powerLevelUsers)
|
||||||
final powerLevels = powerLevelsEventId == null
|
.addAll(joinedMembers)
|
||||||
? null
|
.map((userId) => userId.substring(userId.indexOf(":") + 1))
|
||||||
: room.events[powerLevelsEventId];
|
.whereNoDuplicates()
|
||||||
|
.take(5)
|
||||||
if (powerLevels?.content case PowerLevelsContent(:final users)) {
|
.toISet();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return servers.isEmpty
|
return servers.isEmpty
|
||||||
? ""
|
? ""
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/emoji_picker.dart";
|
import "package:nexus/widgets/emoji_picker.dart";
|
||||||
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
|
||||||
extension BuildEventOptions on Event {
|
extension BuildEventOptions on Event {
|
||||||
|
|
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
|
||||||
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
...{
|
...{
|
||||||
|
// Custom emoji are sent without their shortcode, as only
|
||||||
|
// their mxc URL is recorded
|
||||||
...ref
|
...ref
|
||||||
.read(RecentEmojiController.provider)
|
.read(RecentEmojiController.provider)
|
||||||
.map((entry) => entry.emoji),
|
.map((entry) => entry.emoji),
|
||||||
|
|
@ -109,7 +112,12 @@ extension BuildEventOptions on Event {
|
||||||
.take(isDesktop ? 3 : 5)
|
.take(isDesktop ? 3 : 5)
|
||||||
.map(
|
.map(
|
||||||
(emoji) => IconButton(
|
(emoji) => IconButton(
|
||||||
icon: Text(
|
icon: emoji.startsWith("mxc://")
|
||||||
|
? SizedBox.square(
|
||||||
|
dimension: isDesktop ? 18 : 24,
|
||||||
|
child: MxcEmoji(Uri.parse(emoji)),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
emoji,
|
emoji,
|
||||||
style: .new(fontSize: isDesktop ? null : 18),
|
style: .new(fontSize: isDesktop ? null : 18),
|
||||||
),
|
),
|
||||||
|
|
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
|
||||||
|
|
||||||
context.showAdaptiveSheet(
|
context.showAdaptiveSheet(
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
|
roomId: roomId,
|
||||||
allowFreeText: true,
|
allowFreeText: true,
|
||||||
onSelection: (emoji) {
|
onSelection: (emoji, {shortcode, description}) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ref
|
ref
|
||||||
.read(notifier)
|
.read(notifier)
|
||||||
.sendReaction(emoji, this)
|
.sendReaction(emoji, this, shortcode: shortcode)
|
||||||
.onError(showError);
|
.onError(showError);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue