Compare commits

..
Author SHA1 Message Date
06e6575fa3
release v1.0.1 2026-10-25 19:08:50 -04:00
68f7506669
fix invalid power level creation
oops, misread spec and now this breaks rooms on Synapse
2026-10-25 19:06:17 -04:00
103 changed files with 723 additions and 3454 deletions

View file

@ -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.0";
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.1";
```
Then import the module and enable it in your NixOS configuration:

2
gomuks

@ -1 +1 @@
Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310

View file

@ -1,10 +1,7 @@
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> {
@ -12,55 +9,12 @@ class AccountDataController extends Notifier<AccountData> {
AccountData build() => .new();
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
state = newAccountData.entries.fold(state, (data, entry) {
try {
return .fromJson({
...json.decode(json.encode(data.toJson())),
entry.key: entry.value["content"],
state = .fromJson({
...json.decode(json.encode(state.toJson())),
...newAccountData
.map((key, value) => MapEntry(key, value["content"]))
.unlock,
});
} 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,

View file

@ -1,25 +0,0 @@
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,
);
}

View file

@ -29,7 +29,6 @@ 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";
@ -186,7 +185,6 @@ class ClientController extends AsyncNotifier<int> {
...json,
"content": {
...content,
if (request.relatesTo != null)
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
@ -200,11 +198,14 @@ class ClientController extends AsyncNotifier<int> {
Future<String?> setState(SetStateRequest request) async =>
await _sendCommand("set_state", request.toJson());
Future<void> pauseSync() => _sendCommand("pause_sync");
Future<void> resumeSync() => _sendCommand("resume_sync");
Future<void> verify(String recoveryKey) =>
_sendCommand("verify", {"recovery_key": recoveryKey});
Future<String?> verify(String recoveryKey) async {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
return null;
} catch (error) {
return error.toString();
}
}
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
@ -264,15 +265,6 @@ 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 {

View file

@ -1,69 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
/// The image packs the user has enabled everywhere, as room IDs to their
/// packs' state keys, once the packs' state has been loaded.
class EnabledImagePacksController
extends AsyncNotifier<IMap<String, ISet<String>>> {
@override
Future<IMap<String, ISet<String>>> build() async {
final enabledPacks = ref.watch(
AccountDataController.provider.select(
(data) => data.enabledImagePacks.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
),
),
);
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack.
final missingKeys = ref.watch(
RoomsController.provider.select(
(rooms) => enabledPacks.entries
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
.expand(
(entry) => entry.value
.where(
(stateKey) => packTypes.every(
(type) =>
rooms[entry.key]?.state[type.type]?[stateKey] == null,
),
)
.expand(
(stateKey) => packTypes.map(
(type) => RoomStateKey(
roomId: entry.key,
type: type.type,
stateKey: stateKey,
),
),
),
)
.toIList(),
),
);
if (missingKeys.isNotEmpty) {
ref
.read(RoomsController.provider.notifier)
.addSpecificState(
await ref
.read(ClientController.provider.notifier)
.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return enabledPacks;
}
static final provider =
AsyncNotifierProvider.autoDispose<
EnabledImagePacksController,
IMap<String, ISet<String>>
>(EnabledImagePacksController.new);
}

View file

@ -9,12 +9,11 @@ class EventController(final GetEventRequest request)
extends AsyncNotifier<Event?> {
@override
Future<Event?> build() async {
final event = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
final room = ref.watch(
RoomsController.provider.select((value) => value[request.roomId]),
);
final event = room?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
),
),
);
if (event != null) return event;

View file

@ -156,12 +156,11 @@ 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) {
client.pauseSync().onError(showError);
GomuksPauseSync(handle);
} else {
ref.invalidate(TypingController.provider);
client.resumeSync().onError(showError);
GomuksResumeSync(handle);
}
}, fireImmediately: true);
}

View file

@ -1,75 +0,0 @@
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,
);
}

View file

@ -1,43 +0,0 @@
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,
);
}

View file

@ -169,7 +169,7 @@ class NotificationController
if (Platform.isLinux) {
final portal = await ref.read(PortalController.provider.future);
const escape = HtmlEscape(.element);
const escape = HtmlEscape(HtmlEscapeMode.element);
await portal.notification.addNotification(
id.toString(),

View file

@ -23,13 +23,7 @@ class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
),
);
return .new(
recents.where(
(recent) =>
// Is unicode or custom emote
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
),
);
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
}
Future<void> add(String emoji) => ref

View file

@ -9,9 +9,7 @@ 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";
@ -243,14 +241,17 @@ class RoomChatController(final ChatParams info)
ref.invalidate(provider);
}
// 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")(#[^#]*#)?"),
var taggedMessage = text;
for (final tag in tags) {
final escaped = RegExp.escape(tag.id);
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
taggedMessage = taggedMessage.replaceAllMapped(
pattern,
(match) => match.group(1)!,
),
);
}
final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage(
@ -314,24 +315,14 @@ class RoomChatController(final ChatParams info)
}
}
/// 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 {
Future<void> sendReaction(String reaction, Event event) async {
final client = ref.read(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: info.roomId,
type: EventType.reaction.type,
// Wrapped in colons, like other clients send it
content: ReactionContent(
key: reaction,
shortcode: shortcode == null ? null : ":$shortcode:",
),
content: ReactionContent(key: reaction),
synchronous: true,
disableEncryption: true,
relatesTo: event.eventId,
@ -340,33 +331,6 @@ 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,

View file

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

View file

@ -7,12 +7,10 @@ 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";
@ -22,7 +20,6 @@ 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";
@ -30,17 +27,14 @@ 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";
@ -95,7 +89,6 @@ 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));
@ -163,81 +156,20 @@ class RoomSettingsSectionsController(final String roomId)
ClientStateController.provider.select((state) => state?.userId),
);
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
final encrypted = stateContent(.encryption) is EncryptionContent;
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them
final messageType = stateContent(.encryption) is EncryptionContent
? EventType.encrypted
: EventType.message;
Future<void> setState(
EventType type,
Content content, {
String stateKey = "",
}) async {
Future<void> setState(EventType type, Content content) async {
final eventId = await client.setState(
.new(
roomId: roomId,
type: type.type,
stateKey: stateKey,
content: content,
),
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
);
if (eventId case final eventId?) {
await roomsController.awaitState(
roomId,
type.type,
eventId,
stateKey: stateKey,
);
await roomsController.awaitState(roomId, type.type, eventId);
}
}
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,
@ -286,10 +218,12 @@ 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 = shownEvents
.addAll(PowerLevelGroup.values.expand((group) => group.events))
.map((event) => event.type.type)
.toISet();
final listedEvents = [
messageType,
EventType.sticker,
EventType.reaction,
EventType.pinnedEvents,
].map((type) => type.type).toISet();
final otherEvents = powerLevels.events.entries
.where((entry) => !listedEvents.contains(entry.key))
.toIList()
@ -354,94 +288,20 @@ class RoomSettingsSectionsController(final String roomId)
),
);
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),
/// 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,
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),
),
],
);
},
icon: icon,
value: powerLevels.events[type.type] ?? fallback,
update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(type.type, level),
),
);
@ -571,7 +431,14 @@ class RoomSettingsSectionsController(final String roomId)
subtitle: Text(description),
value: joinRules.joinRule,
options: joinRuleOptions,
getName: (rule) => rule.displayName,
getName: (rule) => switch (rule) {
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
},
onChanged: canSetJoinRules
? (rule) => setState(
.joinRules,
@ -731,7 +598,6 @@ class RoomSettingsSectionsController(final String roomId)
.invited => "After invite",
.joined => "After join",
.worldReadable => "Anyone",
.unknown => "Unknown",
},
onChanged: canSetHistoryVisibility
? (visibility) => setState(
@ -749,7 +615,13 @@ class RoomSettingsSectionsController(final String roomId)
title: "Permissions",
icon: Icons.admin_panel_settings,
settings: .new([
permissionSetting(.usersDefault),
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),
),
header("Users", "Power levels of specific users.", Icons.group),
...users.map((entry) {
final member = memberOf(entry.key);
@ -810,31 +682,87 @@ 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,
),
...shownEvents
.where((event) => event != .pinnedEvents)
.map(
(event) =>
eventSetting(event, fallback: powerLevels.eventsDefault),
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),
),
permissionSetting(.eventsDefault),
header(
"State Events",
"Power levels needed to change the room's state.",
"Power levels needed to manage members and change the room's state.",
Icons.settings,
),
permissionSetting(.invite),
permissionSetting(.stateDefault),
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),
),
header(
"Other Events",
"Power levels for specific event types.",
@ -885,90 +813,6 @@ class RoomSettingsSectionsController(final String roomId)
),
]),
),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...imagePacks.entries.map((entry) {
final stateKey = entry.key;
final pack = entry.value;
final canEdit =
canCreatePacks &&
(canSetLegacyPacks || !hasLegacyPack(stateKey));
return Setting(
title: pack.name,
description: pack.imageCountLabel,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey(stateKey),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(pack.icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: canEdit ? "Edit Image Pack" : title,
content: pack.content,
onSave: canEdit
? (content) => saving.run(
() => setPack(
stateKey,
content,
).onError(showError),
)
: null,
onDeleted: canEdit
? () => saving.run(
() => setPack(
stateKey,
const .new(),
).onError(showError),
)
: null,
),
),
);
},
),
);
}),
.new(
title: "Create Pack",
description: "Add a pack of emoticons and stickers to the room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canCreatePacks && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: "Create Pack",
content: const .new(),
onSave: (content) => saving.run(
() => setState(
.imagePack,
content,
stateKey: newPackKey(content.pack!.displayName!),
).onError(showError),
),
),
),
);
},
),
),
]),
),
]),
});
}

View file

@ -11,25 +11,16 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
update(
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(
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
state: .new(
state
.groupListsBy((event) => event.type)
@ -40,6 +31,9 @@ class RoomsController extends Notifier<IMap<String, Room>> {
),
),
),
),
}),
.new(),
);
static IMap<String, IList<ReadReceipt>> mergeReceipts(

View file

@ -9,31 +9,22 @@ 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";
@ -44,33 +35,6 @@ 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,
@ -140,26 +104,6 @@ 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(
@ -265,21 +209,6 @@ 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(
@ -335,78 +264,6 @@ 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,

View file

@ -25,50 +25,55 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
);
/// 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)),
);
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;
}
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(
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,
),
children: .new(directRooms),
subSpaces: .new(subSpaces),
);
}

View file

@ -1,48 +1,53 @@
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 powerLevelUsers =
switch (room.state[EventType.powerLevels.type]?[""]) {
final rowId? => switch (room.events[rowId]?.content) {
PowerLevelsContent(:final users) => users.keys.toIList(),
_ => null,
},
null => null,
} ??
.new([]);
final servers = <String>{};
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();
void addUserId(String? userId) {
final server = userId?.split(":").lastOrNull;
if (server != null) {
servers.add(server);
}
}
final servers = [?ref.watch(ClientStateController.provider)?.userId]
.toIList()
.addAll(powerLevelUsers)
.addAll(joinedMembers)
.map((userId) => userId.substring(userId.indexOf(":") + 1))
.whereNoDuplicates()
.take(5)
.toISet();
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;
}
return servers.isEmpty
? ""

View file

@ -18,7 +18,6 @@ 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 {
@ -98,8 +97,6 @@ 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),
@ -112,12 +109,7 @@ extension BuildEventOptions on Event {
.take(isDesktop ? 3 : 5)
.map(
(emoji) => IconButton(
icon: emoji.startsWith("mxc://")
? SizedBox.square(
dimension: isDesktop ? 18 : 24,
child: MxcEmoji(Uri.parse(emoji)),
)
: Text(
icon: Text(
emoji,
style: .new(fontSize: isDesktop ? null : 18),
),
@ -142,13 +134,12 @@ extension BuildEventOptions on Event {
context.showAdaptiveSheet(
(context) => EmojiPicker(
roomId: roomId,
allowFreeText: true,
onSelection: (emoji, {shortcode, description}) {
onSelection: (emoji) {
Navigator.of(context).pop();
ref
.read(notifier)
.sendReaction(emoji, this, shortcode: shortcode)
.sendReaction(emoji, this)
.onError(showError);
},
),

Some files were not shown because too many files have changed in this diff Show more