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: 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.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: 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 "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> {
@ -12,56 +9,13 @@ 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 = newAccountData.entries.fold(state, (data, entry) { state = .fromJson({
try { ...json.decode(json.encode(state.toJson())),
return .fromJson({ ...newAccountData
...json.decode(json.encode(data.toJson())), .map((key, value) => MapEntry(key, value["content"]))
entry.key: entry.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>( static final provider = NotifierProvider<AccountDataController, AccountData>(
AccountDataController.new, 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_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";
@ -186,12 +185,11 @@ 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, "rel_type": request.relationType,
"rel_type": request.relationType, },
},
}, },
}), }),
); );
@ -200,11 +198,14 @@ 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<void> pauseSync() => _sendCommand("pause_sync"); Future<String?> verify(String recoveryKey) async {
Future<void> resumeSync() => _sendCommand("resume_sync"); try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
Future<void> verify(String recoveryKey) => return null;
_sendCommand("verify", {"recovery_key": recoveryKey}); } catch (error) {
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}),
@ -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( Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request, GetRelatedEventsRequest request,
) async { ) 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?> { extends AsyncNotifier<Event?> {
@override @override
Future<Event?> build() async { Future<Event?> build() async {
final event = ref.watch( final room = ref.watch(
RoomsController.provider.select( RoomsController.provider.select((value) => value[request.roomId]),
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( );
(event) => event.eventId == request.eventId, final event = room?.events.values.firstWhereOrNull(
), (event) => event.eventId == request.eventId,
),
); );
if (event != null) return event; 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 // 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) {
client.pauseSync().onError(showError); GomuksPauseSync(handle);
} else { } else {
ref.invalidate(TypingController.provider); ref.invalidate(TypingController.provider);
client.resumeSync().onError(showError); GomuksResumeSync(handle);
} }
}, fireImmediately: true); }, 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) { if (Platform.isLinux) {
final portal = await ref.read(PortalController.provider.future); final portal = await ref.read(PortalController.provider.future);
const escape = HtmlEscape(.element); const escape = HtmlEscape(HtmlEscapeMode.element);
await portal.notification.addNotification( await portal.notification.addNotification(
id.toString(), id.toString(),

View file

@ -23,13 +23,7 @@ class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
), ),
); );
return .new( return .new(recents.where((recent) => emoji.contains(recent.emoji)));
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

View file

@ -9,9 +9,7 @@ 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";
@ -243,14 +241,17 @@ class RoomChatController(final ChatParams info)
ref.invalidate(provider); ref.invalidate(provider);
} }
// Replaces each formatted tag, like "@id#name#", with its ID var taggedMessage = text;
final taggedMessage = tags.fold(
text, for (final tag in tags) {
(message, tag) => message.replaceAllMapped( final escaped = RegExp.escape(tag.id);
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"), final pattern = RegExp(r"@+(" + escaped + 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(
@ -314,24 +315,14 @@ class RoomChatController(final ChatParams info)
} }
} }
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji, Future<void> sendReaction(String reaction, Event event) async {
/// 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,
// Wrapped in colons, like other clients send it content: ReactionContent(key: reaction),
content: ReactionContent(
key: reaction,
shortcode: shortcode == null ? null : ":$shortcode:",
),
synchronous: true, synchronous: true,
disableEncryption: true, disableEncryption: true,
relatesTo: event.eventId, 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 static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>( .autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new, 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/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";
@ -22,7 +20,6 @@ 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";
@ -30,17 +27,14 @@ 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";
@ -95,7 +89,6 @@ 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));
@ -163,81 +156,20 @@ 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);
final encrypted = stateContent(.encryption) is EncryptionContent; // Encrypted rooms send messages as m.room.encrypted, so that's what limits them
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); final messageType = stateContent(.encryption) is EncryptionContent
? EventType.encrypted
: EventType.message;
Future<void> setState( Future<void> setState(EventType type, Content content) async {
EventType type,
Content content, {
String stateKey = "",
}) async {
final eventId = await client.setState( final eventId = await client.setState(
.new( .new(roomId: roomId, type: type.type, stateKey: "", content: content),
roomId: roomId,
type: type.type,
stateKey: stateKey,
content: content,
),
); );
if (eventId case final eventId?) { if (eventId case final eventId?) {
await roomsController.awaitState( await roomsController.awaitState(roomId, type.type, eventId);
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,
@ -286,10 +218,12 @@ 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 = shownEvents final listedEvents = [
.addAll(PowerLevelGroup.values.expand((group) => group.events)) messageType,
.map((event) => event.type.type) EventType.sticker,
.toISet(); EventType.reaction,
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()
@ -354,94 +288,20 @@ class RoomSettingsSectionsController(final String roomId)
), ),
); );
Setting permissionSetting(PowerLevelPermission permission) => /// A setting for the level needed to send [type], which otherwise needs [fallback].
powerLevelSetting( Setting eventSetting(
title: permission.title, EventType type, {
description: permission.description, required String title,
icon: permission.icon, required String description,
value: permission.levelIn(powerLevels), required IconData icon,
update: (level) => permission.withLevel(powerLevels, level), required int fallback,
); }) => powerLevelSetting(
title: title,
/// A setting for the level needed to send [event], which otherwise needs [fallback]. description: description,
Setting eventSetting(PowerLevelEvent event, {required int fallback}) => icon: icon,
powerLevelSetting( value: powerLevels.events[type.type] ?? fallback,
title: event.title, update: (level) => powerLevels.copyWith(
description: event.description, events: powerLevels.events.add(type.type, level),
icon: event.icon,
value: powerLevels.events[event.type.type] ?? fallback,
update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(event.type.type, level),
),
);
Widget tileOf(Setting setting) =>
setting.builder(setting.title, setting.description, setting.icon);
/// A setting for all of [group]'s levels at once, which expands to set each.
Setting groupSetting(PowerLevelGroup group) => .new(
title: group.title,
description: group.description,
icon: group.icon,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final expanded = useState(false);
return Column(
mainAxisSize: .min,
children: [
PowerLevelTile(
leading: Icon(icon),
title: title,
description: description,
value: group.levelIn(powerLevels),
ownLevel: ownLevel,
// Changing it would change levels above the user's own
onChanged:
canSetPowerLevels &&
group.highestIn(powerLevels) <= ownLevel
? (level) =>
setPowerLevels(group.withLevel(powerLevels, level))
: null,
trailing: IconButton(
tooltip: expanded.value ? "Collapse" : "Set each",
icon: AnimatedRotation(
turns: expanded.value ? 0.5 : 0,
duration: Durations.medium2,
child: Icon(Icons.expand_more),
),
onPressed: () => expanded.value = !expanded.value,
),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: expanded.value
? Padding(
padding: .only(left: 16),
child: Column(
mainAxisSize: .min,
children: [
...group.permissions
.map(permissionSetting)
.map(tileOf),
...group.events
.map(
(event) => eventSetting(
event,
fallback: powerLevels.stateDefault,
),
)
.map(tileOf),
],
),
)
: const SizedBox(width: .infinity),
),
],
);
},
), ),
); );
@ -571,7 +431,14 @@ class RoomSettingsSectionsController(final String roomId)
subtitle: Text(description), subtitle: Text(description),
value: joinRules.joinRule, value: joinRules.joinRule,
options: joinRuleOptions, 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 onChanged: canSetJoinRules
? (rule) => setState( ? (rule) => setState(
.joinRules, .joinRules,
@ -731,7 +598,6 @@ 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(
@ -749,7 +615,13 @@ class RoomSettingsSectionsController(final String roomId)
title: "Permissions", title: "Permissions",
icon: Icons.admin_panel_settings, icon: Icons.admin_panel_settings,
settings: .new([ 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), 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);
@ -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( 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,
), ),
...shownEvents eventSetting(
.where((event) => event != .pinnedEvents) messageType,
.map( title: "Send Messages",
(event) => description: "The power level needed to send messages.",
eventSetting(event, fallback: powerLevels.eventsDefault), icon: Icons.message,
), fallback: powerLevels.eventsDefault,
permissionSetting(.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),
),
header( header(
"State Events", "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, Icons.settings,
), ),
permissionSetting(.invite), eventSetting(
permissionSetting(.stateDefault), .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( header(
"Other Events", "Other Events",
"Power levels for specific event types.", "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,35 +11,29 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override @override
IMap<String, Room> build() => .new(); IMap<String, Room> build() => .new();
void addState(String roomId, IList<Event> state, {bool isMembers = false}) => void addState(
update( String roomId,
.new({ IList<Event> state, {
roomId: stateRoom(state) bool isMembers = false,
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers), }) => update(
}), .new({
.new(), roomId: Room(
); events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
void addSpecificState(Iterable<Event> state) => update( hasFetchedMembers: isMembers,
.new( state: .new(
state state
.groupListsBy((event) => event.roomId) .groupListsBy((event) => event.type)
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))), .map(
), (type, group) => .new(
); type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
static Room stateRoom(Iterable<Event> state) => Room( ),
events: .fromEntries(state.map((event) => .new(event.rowId, event))), ),
state: .new( ),
state ),
.groupListsBy((event) => event.type) }),
.map( .new(),
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
),
),
); );
static IMap<String, IList<ReadReceipt>> mergeReceipts( 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/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";
@ -44,33 +35,6 @@ 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,
@ -140,26 +104,6 @@ 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(
@ -265,21 +209,6 @@ 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(
@ -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( .new(
title: "Notifications", title: "Notifications",
icon: Icons.notifications, icon: Icons.notifications,

View file

@ -25,50 +25,55 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
); );
/// Everything under [spaceId], however deep, skipping any already Set<String> collectDescendants(String startId) {
/// [visited] so cycles end. final visited = <String>{};
ISet<String> collectDescendants( final stack = [startId];
String spaceId, [
ISet<String> visited = const .empty(), while (stack.isNotEmpty) {
]) => (childrenById[spaceId] ?? const []).fold( final current = stack.removeLast();
visited, final children = childrenById[current] ?? const [];
(visited, childId) => visited.contains(childId)
? visited for (final child in children) {
: collectDescendants(childId, visited.add(childId)), if (visited.add(child)) {
); 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( children: .new(directRooms),
directChildrenIds subSpaces: .new(subSpaces),
.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,
),
); );
} }

View file

@ -1,48 +1,53 @@
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 powerLevelUsers = final servers = <String>{};
switch (room.state[EventType.powerLevels.type]?[""]) {
final rowId? => switch (room.events[rowId]?.content) {
PowerLevelsContent(:final users) => users.keys.toIList(),
_ => null,
},
null => null,
} ??
.new([]);
final joinedMembers = void addUserId(String? userId) {
(room.state[EventType.membership.type]?.values ?? const []) final server = userId?.split(":").lastOrNull;
.map( if (server != null) {
(rowId) => switch (room.events[rowId]) { servers.add(server);
Event( }
stateKey: final userId?, }
content: MembershipContent(status: .join),
) =>
userId,
_ => null,
},
)
.nonNulls
.toIList();
final servers = [?ref.watch(ClientStateController.provider)?.userId] addUserId(ref.watch(ClientStateController.provider)?.userId);
.toIList()
.addAll(powerLevelUsers) final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
.addAll(joinedMembers) final powerLevels = powerLevelsEventId == null
.map((userId) => userId.substring(userId.indexOf(":") + 1)) ? null
.whereNoDuplicates() : room.events[powerLevelsEventId];
.take(5)
.toISet(); 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 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/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 {
@ -98,8 +97,6 @@ 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),
@ -112,15 +109,10 @@ extension BuildEventOptions on Event {
.take(isDesktop ? 3 : 5) .take(isDesktop ? 3 : 5)
.map( .map(
(emoji) => IconButton( (emoji) => IconButton(
icon: emoji.startsWith("mxc://") icon: Text(
? SizedBox.square( emoji,
dimension: isDesktop ? 18 : 24, style: .new(fontSize: isDesktop ? null : 18),
child: MxcEmoji(Uri.parse(emoji)), ),
)
: Text(
emoji,
style: .new(fontSize: isDesktop ? null : 18),
),
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
await ref await ref
@ -142,13 +134,12 @@ extension BuildEventOptions on Event {
context.showAdaptiveSheet( context.showAdaptiveSheet(
(context) => EmojiPicker( (context) => EmojiPicker(
roomId: roomId,
allowFreeText: true, allowFreeText: true,
onSelection: (emoji, {shortcode, description}) { onSelection: (emoji) {
Navigator.of(context).pop(); Navigator.of(context).pop();
ref ref
.read(notifier) .read(notifier)
.sendReaction(emoji, this, shortcode: shortcode) .sendReaction(emoji, this)
.onError(showError); .onError(showError);
}, },
), ),

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