Compare commits

..
Author SHA1 Message Date
cb983701ca
make sdk bound less strict 2026-10-28 10:47:17 -04:00
285e6ad76a
check that formattedBody exists before rendering a message as HTML
In case people send HTML messages without HTML, which they do for some reason.
2026-10-26 23:54:28 -04:00
cd60163caf
update gomuks vendorHash 2026-10-26 17:35:52 -04:00
a2638d5c82
bump gomuks, use new pause/resume commands 2026-10-26 17:32:06 -04:00
f971c50df0
use a text span with a recognizer instead of a WidgetSpan
Fixes displaynames not ellipsising properly
2026-10-26 12:45:54 -04:00
1efbc131b4
auto write desktop file on debug mode install on Linux
Fixes #150
2026-10-26 12:13:06 -04:00
4b329b4c6f
add settings for image packs
Closes #131
2026-10-26 12:05:45 -04:00
2041b81141
add toJson to all content models
Fixes a regression from the primary constructor content migration
2026-10-25 18:30:28 -04:00
a3ef4f8135
fix invalid power level creation
oops, misread spec and now this breaks rooms on Synapse
2026-10-25 17:04:21 -04:00
0a09a4ee0a
general cleanups and mounted guards 2026-10-24 12:02:23 -04:00
62e200f305
fix typing indicators not ellipsizing 2026-10-24 11:30:14 -04:00
dd01ad9d74
migrate Content classes to primary constructors
The Riverpod bug preventing us from doing this has been fixed.
2026-10-23 20:44:29 -04:00
853116ab2f
treewide cleanups 2026-10-23 20:26:37 -04:00
53d9c2b1e6
make enums fall back to unknown or null where possible
Futureproofing.
2026-10-23 20:12:54 -04:00
401b527714
ignore invalid account data 2026-10-23 20:05:23 -04:00
3754bc2feb
allow sending emoji/stickers from emote packs 2026-10-23 18:16:45 -04:00
868023a4a1
avoid un-needed reloads for diffs 2026-10-23 17:02:41 -04:00
9e6951818a
hide server ACL events from timeline by default, introduce setting to show them 2026-10-23 14:29:37 -04:00
0743c4cfc6
reorder appearance settings 2026-10-23 14:26:26 -04:00
6336764da0
add power level groups 2026-10-23 12:24:22 -04:00
3a39f1c5ac
more intuitive permissions in encrypted rooms 2026-10-23 12:12:20 -04:00
6e2d56ec75
add diffing for a lot of events 2026-10-23 12:01:38 -04:00
4e140e9fc7
add pinned event and server ACL diffing 2026-10-23 11:28:44 -04:00
e5dd53ed6c
tweak color picking algorithm for power levels, and introduce setting for per PL or per user colors 2026-10-23 10:23:49 -04:00
c0f4c053b0
Make displayname colors based off of power level
Fixes #128
2026-10-23 01:10:01 -04:00
ee36a9b1c8
Revert "use media_kit from main"
This reverts commit f5371382d7.
2026-10-23 00:36:31 -04:00
f5371382d7
use media_kit from main
Has some perf improvements, especially for Linux
2026-10-23 00:28:43 -04:00
103 changed files with 3454 additions and 723 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.1"; url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0";
``` ```
Then import the module and enable it in your NixOS configuration: Then import the module and enable it in your NixOS configuration:

2
gomuks

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

View file

@ -1,7 +1,10 @@
import "dart:async";
import "dart:convert"; import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
class AccountDataController extends Notifier<AccountData> { class AccountDataController extends Notifier<AccountData> {
@ -9,13 +12,56 @@ class AccountDataController extends Notifier<AccountData> {
AccountData build() => .new(); AccountData build() => .new();
void update(IMap<String, IMap<String, dynamic>> newAccountData) => void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
state = .fromJson({ state = newAccountData.entries.fold(state, (data, entry) {
...json.decode(json.encode(state.toJson())), try {
...newAccountData return .fromJson({
.map((key, value) => MapEntry(key, value["content"])) ...json.decode(json.encode(data.toJson())),
.unlock, entry.key: entry.value["content"],
});
} catch (error) {
debugPrint("Ignoring invalid ${entry.key} account data: $error");
return data;
}
}); });
Future<void> setEnabledImagePacks(ImagePackRooms packs) async {
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
.toISet();
final keys = keysOf(packs);
bool arrived(AccountData data) {
final current = keysOf(data.enabledImagePacks);
return current.length == keys.length && current.containsAll(keys);
}
final completer = Completer<void>();
final removeListener = listenSelf((_, data) {
if (arrived(data) && !completer.isCompleted) completer.complete();
});
try {
final client = ref.read(ClientController.provider.notifier);
await Future.wait([
if (state.imagePackRooms != null)
client.setAccountData(
.new(type: AccountData.imagePackRoomsKey, content: packs),
),
client.setAccountData(
.new(type: AccountData.legacyImagePackRoomsKey, content: packs),
),
]);
if (!arrived(state)) {
await completer.future.timeout(
const .new(seconds: 10),
onTimeout: () {},
);
}
} finally {
removeListener();
}
}
static final provider = NotifierProvider<AccountDataController, AccountData>( static final provider = NotifierProvider<AccountDataController, AccountData>(
AccountDataController.new, AccountDataController.new,
); );

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
class AllEmojiController(final String roomId)
extends AsyncNotifier<IList<EmojiCategory>> {
@override
Future<IList<EmojiCategory>> build() async {
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
final unicode = await ref.watch(EmojiController.provider.future);
return packs
.map((pack) => pack.emojiCategory)
.nonNulls
.toIList()
.addAll(unicode);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<AllEmojiController, IList<EmojiCategory>, String>(
AllEmojiController.new,
);
}

View file

@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart"; import "package:nexus/models/profile_response.dart";
@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier<int> {
...json, ...json,
"content": { "content": {
...content, ...content,
"m.relates_to": { if (request.relatesTo != null)
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}), "m.relates_to": {
"event_id": request.relatesTo, ...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"rel_type": request.relationType, "event_id": request.relatesTo,
}, "rel_type": request.relationType,
},
}, },
}), }),
); );
@ -198,14 +200,11 @@ class ClientController extends AsyncNotifier<int> {
Future<String?> setState(SetStateRequest request) async => Future<String?> setState(SetStateRequest request) async =>
await _sendCommand("set_state", request.toJson()); await _sendCommand("set_state", request.toJson());
Future<String?> verify(String recoveryKey) async { Future<void> pauseSync() => _sendCommand("pause_sync");
try { Future<void> resumeSync() => _sendCommand("resume_sync");
await _sendCommand("verify", {"recovery_key": recoveryKey});
return null; Future<void> verify(String recoveryKey) =>
} catch (error) { _sendCommand("verify", {"recovery_key": recoveryKey});
return error.toString();
}
}
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson( Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
@ -265,6 +264,15 @@ class ClientController extends AsyncNotifier<int> {
); );
} }
Future<IList<Event>> getSpecificRoomState(
GetSpecificRoomStateRequest request,
) async => .new(
((await _sendCommand("get_specific_room_state", request.toJson()))
as List? ??
[])
.map((event) => .fromJson(event)),
);
Future<IList<Event>?> getRelatedEvents( Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request, GetRelatedEventsRequest request,
) async { ) async {

View file

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

View file

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

View file

@ -156,11 +156,12 @@ class GomuksListenerController extends AsyncNotifier<void> {
// Mobile OSes block network access for backgrounded apps, so pause // Mobile OSes block network access for backgrounded apps, so pause
// syncing instead of letting it fail. // syncing instead of letting it fail.
ref.listen(AppPausedController.provider, (_, paused) { ref.listen(AppPausedController.provider, (_, paused) {
final client = ref.read(ClientController.provider.notifier);
if (paused) { if (paused) {
GomuksPauseSync(handle); client.pauseSync().onError(showError);
} else { } else {
ref.invalidate(TypingController.provider); ref.invalidate(TypingController.provider);
GomuksResumeSync(handle); client.resumeSync().onError(showError);
} }
}, fireImmediately: true); }, fireImmediately: true);
} }

View file

@ -0,0 +1,75 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/enabled_image_packs.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
typedef ImagePackSource = ({String roomId, String? stateKey});
class ImagePackSourcesController(final String roomId)
extends AsyncNotifier<IList<ImagePackSource>> {
@override
Future<IList<ImagePackSource>> build() async {
final enabledPacksFuture = ref.watch(
EnabledImagePacksController.provider.future,
);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final directParents = ref.watch(
RoomsController.provider.select(
(rooms) =>
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
const .empty(),
),
);
Iterable<String> parentsOf(String childId) => spaceEdges.entries
.where((entry) => entry.value.any((edge) => edge.childId == childId))
.map((entry) => entry.key)
.followedBy(childId == roomId ? directParents : const []);
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
parentsOf(childId).fold(
spaces,
(spaces, parentId) => spaces.contains(parentId)
? spaces
: withAncestors(spaces.add(parentId), parentId),
);
final rooms = ref.read(RoomsController.provider);
final spaces = withAncestors(
const .empty(),
roomId,
).remove(roomId).where(rooms.containsKey).toIList();
final client = ref.read(ClientController.provider.notifier);
final roomsNotifier = ref.read(RoomsController.provider.notifier);
await Future.wait(
spaces
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
.map(
(spaceId) async => roomsNotifier.addState(
spaceId,
await client.getRoomState(.new(roomId: spaceId)),
),
),
);
final enabledPacks = await enabledPacksFuture;
return .new([
(roomId: roomId, stateKey: null),
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
...enabledPacks.entries.expand(
(entry) => entry.value.map(
(stateKey) => (roomId: entry.key, stateKey: stateKey),
),
),
]);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
ImagePackSourcesController.new,
);
}

View file

@ -0,0 +1,43 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/image_pack_sources.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/models/image_pack.dart";
class ImagePacksController(final String roomId)
extends AsyncNotifier<IList<ImagePack>> {
@override
Future<IList<ImagePack>> build() async {
final sources = await ref.watch(
ImagePackSourcesController.provider(roomId).future,
);
final packs = ref.watch(
RoomsController.provider.select(
(rooms) => sources.expand((source) {
final packs =
rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty();
return switch (source.stateKey) {
final stateKey? => [?packs[stateKey]],
null => packs.values,
};
}).toIList(),
),
);
return .new(
packs
.whereNoDuplicates(by: (pack) => pack.id)
.where(
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
),
);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePacksController, IList<ImagePack>, String>(
ImagePacksController.new,
);
}

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(HtmlEscapeMode.element); const escape = HtmlEscape(.element);
await portal.notification.addNotification( await portal.notification.addNotification(
id.toString(), id.toString(),

View file

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

View file

@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/reaction.dart"; import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/direction.dart"; import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
@ -241,17 +243,14 @@ class RoomChatController(final ChatParams info)
ref.invalidate(provider); ref.invalidate(provider);
} }
var taggedMessage = text; // Replaces each formatted tag, like "@id#name#", with its ID
final taggedMessage = tags.fold(
for (final tag in tags) { text,
final escaped = RegExp.escape(tag.id); (message, tag) => message.replaceAllMapped(
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
taggedMessage = taggedMessage.replaceAllMapped(
pattern,
(match) => match.group(1)!, (match) => match.group(1)!,
); ),
} );
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage( final event = await client.sendMessage(
@ -315,14 +314,24 @@ class RoomChatController(final ChatParams info)
} }
} }
Future<void> sendReaction(String reaction, Event event) async { /// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
/// with their [shortcode].
Future<void> sendReaction(
String reaction,
Event event, {
String? shortcode,
}) async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
await client.sendEvent( await client.sendEvent(
.new( .new(
roomId: info.roomId, roomId: info.roomId,
type: EventType.reaction.type, type: EventType.reaction.type,
content: ReactionContent(key: reaction), // Wrapped in colons, like other clients send it
content: ReactionContent(
key: reaction,
shortcode: shortcode == null ? null : ":$shortcode:",
),
synchronous: true, synchronous: true,
disableEncryption: true, disableEncryption: true,
relatesTo: event.eventId, relatesTo: event.eventId,
@ -331,6 +340,33 @@ class RoomChatController(final ChatParams info)
); );
} }
Future<void> sendSticker(String shortcode, PackImage image) async {
final event = await ref
.read(ClientController.provider.notifier)
.sendEvent(
.new(
roomId: info.roomId,
type: EventType.sticker.type,
content: StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
),
);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
}
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, RoomChat?, ChatParams>( .autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new, RoomChatController.new,

View file

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

View file

@ -7,10 +7,12 @@ import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/power_levels_saving.dart"; import "package:nexus/controllers/power_levels_saving.dart";
import "package:nexus/controllers/room_image_packs.dart";
import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/join_rule_name.dart";
import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -20,6 +22,7 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -27,14 +30,17 @@ import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/topic.dart"; import "package:nexus/models/content/topic.dart";
import "package:nexus/models/join_rule.dart"; import "package:nexus/models/join_rule.dart";
import "package:nexus/models/power_level_permission.dart";
import "package:nexus/models/setting.dart"; import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/image_pack_sheet.dart";
import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/local_aliases_sheet.dart";
import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
import "package:nexus/widgets/settings/power_level_tile.dart"; import "package:nexus/widgets/settings/power_level_tile.dart";
@ -89,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId)
final canSetJoinRules = canSet(.joinRules); final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility); final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias); final canSetAliases = canSet(.canonicalAlias);
final canCreatePacks = canSet(.imagePack);
final canSetPowerLevels = final canSetPowerLevels =
canSet(.powerLevels) && canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId)); !ref.watch(PowerLevelsSavingController.provider(roomId));
@ -156,20 +163,81 @@ class RoomSettingsSectionsController(final String roomId)
ClientStateController.provider.select((state) => state?.userId), ClientStateController.provider.select((state) => state?.userId),
); );
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them final encrypted = stateContent(.encryption) is EncryptionContent;
final messageType = stateContent(.encryption) is EncryptionContent final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
? EventType.encrypted
: EventType.message;
Future<void> setState(EventType type, Content content) async { Future<void> setState(
EventType type,
Content content, {
String stateKey = "",
}) async {
final eventId = await client.setState( final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content), .new(
roomId: roomId,
type: type.type,
stateKey: stateKey,
content: content,
),
); );
if (eventId case final eventId?) { if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId); await roomsController.awaitState(
roomId,
type.type,
eventId,
stateKey: stateKey,
);
} }
} }
final imagePacks =
ref.watch(RoomImagePacksController.provider(roomId)).value ??
const .empty();
// Pack sheets can stay open while the state changes, so these read it when
// called, rather than using the state from building
IMap<String, IMap<String, int>> currentState() =>
ref.read(RoomsController.provider)[roomId]?.state ?? const .empty();
// Whether the legacy type still has a pack at [stateKey], to clear it
bool hasLegacyPack(String stateKey) =>
switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) {
final rowId? => switch (contentOf(rowId)) {
final ImagePackContent content => content.hasContent,
_ => false,
},
null => false,
};
final canSetLegacyPacks = canSet(.legacyImagePack);
/// Saves the pack at [stateKey] as the stable type, migrating it by
/// clearing its legacy event if it has one.
Future<void> setPack(String stateKey, Content content) async {
await setState(.imagePack, content, stateKey: stateKey);
if (hasLegacyPack(stateKey)) {
await setState(.legacyImagePack, const .new(), stateKey: stateKey);
}
}
/// A state key for a new pack named [name]. It's one no pack has ever
/// had, even deleted ones, as users may still have those enabled.
String newPackKey(String name) {
final state = currentState();
final usedKeys = [
EventType.imagePack,
EventType.legacyImagePack,
].expand((type) => state[type.type]?.keys ?? []).toISet();
final slug = name
.toLowerCase()
.replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_")
.replaceAll(RegExp(r"^_+|_+$"), "");
final base = slug.isEmpty ? "pack" : slug;
return Iterable.generate(
usedKeys.length + 1,
(index) => index == 0 ? base : "${base}_${index + 1}",
).firstWhere((key) => !usedKeys.contains(key));
}
Future<void> setAliases( Future<void> setAliases(
String? canonical, String? canonical,
ISet<String> alternatives, ISet<String> alternatives,
@ -218,12 +286,10 @@ class RoomSettingsSectionsController(final String roomId)
.run(() => setState(.powerLevels, content).onError(showError)); .run(() => setState(.powerLevels, content).onError(showError));
// Event types with their own level that don't have a setting of their own // Event types with their own level that don't have a setting of their own
final listedEvents = [ final listedEvents = shownEvents
messageType, .addAll(PowerLevelGroup.values.expand((group) => group.events))
EventType.sticker, .map((event) => event.type.type)
EventType.reaction, .toISet();
EventType.pinnedEvents,
].map((type) => type.type).toISet();
final otherEvents = powerLevels.events.entries final otherEvents = powerLevels.events.entries
.where((entry) => !listedEvents.contains(entry.key)) .where((entry) => !listedEvents.contains(entry.key))
.toIList() .toIList()
@ -288,20 +354,94 @@ class RoomSettingsSectionsController(final String roomId)
), ),
); );
/// A setting for the level needed to send [type], which otherwise needs [fallback]. Setting permissionSetting(PowerLevelPermission permission) =>
Setting eventSetting( powerLevelSetting(
EventType type, { title: permission.title,
required String title, description: permission.description,
required String description, icon: permission.icon,
required IconData icon, value: permission.levelIn(powerLevels),
required int fallback, update: (level) => permission.withLevel(powerLevels, level),
}) => powerLevelSetting( );
title: title,
description: description, /// A setting for the level needed to send [event], which otherwise needs [fallback].
icon: icon, Setting eventSetting(PowerLevelEvent event, {required int fallback}) =>
value: powerLevels.events[type.type] ?? fallback, powerLevelSetting(
update: (level) => powerLevels.copyWith( title: event.title,
events: powerLevels.events.add(type.type, level), description: event.description,
icon: event.icon,
value: powerLevels.events[event.type.type] ?? fallback,
update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(event.type.type, level),
),
);
Widget tileOf(Setting setting) =>
setting.builder(setting.title, setting.description, setting.icon);
/// A setting for all of [group]'s levels at once, which expands to set each.
Setting groupSetting(PowerLevelGroup group) => .new(
title: group.title,
description: group.description,
icon: group.icon,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final expanded = useState(false);
return Column(
mainAxisSize: .min,
children: [
PowerLevelTile(
leading: Icon(icon),
title: title,
description: description,
value: group.levelIn(powerLevels),
ownLevel: ownLevel,
// Changing it would change levels above the user's own
onChanged:
canSetPowerLevels &&
group.highestIn(powerLevels) <= ownLevel
? (level) =>
setPowerLevels(group.withLevel(powerLevels, level))
: null,
trailing: IconButton(
tooltip: expanded.value ? "Collapse" : "Set each",
icon: AnimatedRotation(
turns: expanded.value ? 0.5 : 0,
duration: Durations.medium2,
child: Icon(Icons.expand_more),
),
onPressed: () => expanded.value = !expanded.value,
),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: expanded.value
? Padding(
padding: .only(left: 16),
child: Column(
mainAxisSize: .min,
children: [
...group.permissions
.map(permissionSetting)
.map(tileOf),
...group.events
.map(
(event) => eventSetting(
event,
fallback: powerLevels.stateDefault,
),
)
.map(tileOf),
],
),
)
: const SizedBox(width: .infinity),
),
],
);
},
), ),
); );
@ -431,14 +571,7 @@ class RoomSettingsSectionsController(final String roomId)
subtitle: Text(description), subtitle: Text(description),
value: joinRules.joinRule, value: joinRules.joinRule,
options: joinRuleOptions, options: joinRuleOptions,
getName: (rule) => switch (rule) { getName: (rule) => rule.displayName,
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
},
onChanged: canSetJoinRules onChanged: canSetJoinRules
? (rule) => setState( ? (rule) => setState(
.joinRules, .joinRules,
@ -598,6 +731,7 @@ class RoomSettingsSectionsController(final String roomId)
.invited => "After invite", .invited => "After invite",
.joined => "After join", .joined => "After join",
.worldReadable => "Anyone", .worldReadable => "Anyone",
.unknown => "Unknown",
}, },
onChanged: canSetHistoryVisibility onChanged: canSetHistoryVisibility
? (visibility) => setState( ? (visibility) => setState(
@ -615,13 +749,7 @@ class RoomSettingsSectionsController(final String roomId)
title: "Permissions", title: "Permissions",
icon: Icons.admin_panel_settings, icon: Icons.admin_panel_settings,
settings: .new([ settings: .new([
powerLevelSetting( permissionSetting(.usersDefault),
title: "Default Power Level",
description: "The power level of users who aren't given one.",
icon: Icons.person,
value: powerLevels.usersDefault,
update: (level) => powerLevels.copyWith(usersDefault: level),
),
header("Users", "Power levels of specific users.", Icons.group), header("Users", "Power levels of specific users.", Icons.group),
...users.map((entry) { ...users.map((entry) {
final member = memberOf(entry.key); final member = memberOf(entry.key);
@ -682,87 +810,31 @@ class RoomSettingsSectionsController(final String roomId)
), ),
), ),
), ),
header(
"Groups",
"Power levels set together, which can each be set on their own.",
Icons.workspaces,
),
...PowerLevelGroup.values.map(groupSetting),
header( header(
"Regular Events", "Regular Events",
"Power levels needed to send messages and other timeline events.", "Power levels needed to send messages and other timeline events.",
Icons.chat, Icons.chat,
), ),
eventSetting( ...shownEvents
messageType, .where((event) => event != .pinnedEvents)
title: "Send Messages", .map(
description: "The power level needed to send messages.", (event) =>
icon: Icons.message, eventSetting(event, fallback: powerLevels.eventsDefault),
fallback: powerLevels.eventsDefault, ),
), 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 manage members and change the room's state.", "Power levels needed to change the room's state.",
Icons.settings, Icons.settings,
), ),
eventSetting( permissionSetting(.invite),
.pinnedEvents, permissionSetting(.stateDefault),
title: "Pin Messages",
description: "The power level needed to pin messages.",
icon: Icons.push_pin,
fallback: powerLevels.stateDefault,
),
powerLevelSetting(
title: "Invite Users",
description: "The power level needed to invite users.",
icon: Icons.person_add,
value: powerLevels.invite,
update: (level) => powerLevels.copyWith(invite: level),
),
powerLevelSetting(
title: "Kick Users",
description: "The power level needed to kick users.",
icon: Icons.sports_martial_arts,
value: powerLevels.kick,
update: (level) => powerLevels.copyWith(kick: level),
),
powerLevelSetting(
title: "Ban Users",
description: "The power level needed to ban and unban users.",
icon: Icons.gavel,
value: powerLevels.ban,
update: (level) => powerLevels.copyWith(ban: level),
),
powerLevelSetting(
title: "Delete Others' Messages",
description: "The power level needed to remove others' messages.",
icon: Icons.delete,
value: powerLevels.redact,
update: (level) => powerLevels.copyWith(redact: level),
),
powerLevelSetting(
title: "Default for State Events",
description: "The power level needed to send state events without their own.",
icon: Icons.tune,
value: powerLevels.stateDefault,
update: (level) => powerLevels.copyWith(stateDefault: level),
),
header( header(
"Other Events", "Other Events",
"Power levels for specific event types.", "Power levels for specific event types.",
@ -813,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId)
), ),
]), ]),
), ),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...imagePacks.entries.map((entry) {
final stateKey = entry.key;
final pack = entry.value;
final canEdit =
canCreatePacks &&
(canSetLegacyPacks || !hasLegacyPack(stateKey));
return Setting(
title: pack.name,
description: pack.imageCountLabel,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey(stateKey),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(pack.icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: canEdit ? "Edit Image Pack" : title,
content: pack.content,
onSave: canEdit
? (content) => saving.run(
() => setPack(
stateKey,
content,
).onError(showError),
)
: null,
onDeleted: canEdit
? () => saving.run(
() => setPack(
stateKey,
const .new(),
).onError(showError),
)
: null,
),
),
);
},
),
);
}),
.new(
title: "Create Pack",
description: "Add a pack of emoticons and stickers to the room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canCreatePacks && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: "Create Pack",
content: const .new(),
onSave: (content) => saving.run(
() => setState(
.imagePack,
content,
stateKey: newPackKey(content.pack!.displayName!),
).onError(showError),
),
),
),
);
},
),
),
]),
),
]), ]),
}); });
} }

View file

@ -11,29 +11,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
@override @override
IMap<String, Room> build() => .new(); IMap<String, Room> build() => .new();
void addState( void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
String roomId, update(
IList<Event> state, { .new({
bool isMembers = false, roomId: stateRoom(state)
}) => update( .copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
.new({ }),
roomId: Room( .new(),
events: .fromEntries(state.map((event) => .new(event.rowId, event))), );
hasFetchedState: true,
hasFetchedMembers: isMembers, void addSpecificState(Iterable<Event> state) => update(
state: .new( .new(
state state
.groupListsBy((event) => event.type) .groupListsBy((event) => event.roomId)
.map( .map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
(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)
.new(), .map(
(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,22 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/enabled_image_packs.dart";
import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart"; import "package:nexus/models/client_state.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/models/name_color.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings.dart"; import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart"; import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -35,6 +44,33 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); final settings = await ref.watch(SettingsController.provider.future);
// Loads the enabled packs' state, so they can be shown
ref.watch(EnabledImagePacksController.provider);
final enabledPacks = ref.watch(
AccountDataController.provider.select((data) => data.enabledImagePacks),
);
final enabledPackInfo = ref.watch(
RoomsController.provider.select(
(rooms) => enabledPacks.rooms.entries.expand((entry) {
final room = rooms[entry.key];
final packs = room?.imagePacks(entry.key) ?? const .empty();
return entry.value.keys.map(
(stateKey) => (
roomId: entry.key,
stateKey: stateKey,
roomName: room?.metadata?.name ?? entry.key,
pack: packs[stateKey],
),
);
}).toIList(),
),
);
Future<void> setEnabledPacks(ImagePackRooms packs) => ref
.read(AccountDataController.provider.notifier)
.setEnabledImagePacks(packs);
/// A tile for a [ShowAndSend] setting, saving the settings from [update]. /// A tile for a [ShowAndSend] setting, saving the settings from [update].
Widget showAndSendTile( Widget showAndSendTile(
String title, String title,
@ -104,6 +140,26 @@ class SettingsSectionsController
: null, : null,
), ),
), ),
.new(
title: "Name Colors",
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
icon: Icons.format_color_text,
builder: (title, description, icon) => DialogListTile<NameColor>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: settings.nameColor,
options: NameColor.values,
getName: (option) => switch (option) {
.powerLevel => "By power level",
.user => "By user",
},
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(nameColor: value))
.onError(showError),
),
),
]), ]),
), ),
.new( .new(
@ -209,6 +265,21 @@ class SettingsSectionsController
.onError(showError), .onError(showError),
), ),
), ),
.new(
title: "Hide Server ACL Events",
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
icon: Icons.dns,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.hideServerAclEvents,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(hideServerAclEvents: value))
.onError(showError),
),
),
]), ]),
), ),
.new( .new(
@ -264,6 +335,78 @@ class SettingsSectionsController
), ),
]), ]),
), ),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...enabledPackInfo.map(
(info) => Setting(
title:
info.pack?.name ??
ImagePack.fallbackName(info.roomName, info.stateKey),
description: info.roomName,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey((info.roomId, info.stateKey)),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(info.pack?.icon),
title: Text(title),
subtitle: Text(description),
trailing: IconButton(
tooltip: "Remove pack",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => saving.run(
() => setEnabledPacks(
ref
.read(AccountDataController.provider)
.enabledImagePacks
.withPack(
info.roomId,
info.stateKey,
enabled: false,
),
).onError(showError),
),
),
);
},
),
),
),
.new(
title: "Add Packs",
description:
"Use image packs from any of your rooms in every room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => EnableImagePacksSheet(
enabled: enabledPacks,
onSave: (packs) => saving.run(
() => setEnabledPacks(packs).onError(showError),
),
),
),
);
},
),
),
]),
),
.new( .new(
title: "Notifications", title: "Notifications",
icon: Icons.notifications, icon: Icons.notifications,

View file

@ -25,55 +25,50 @@ class SpacesController extends AsyncNotifier<IList<Space>> {
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
); );
Set<String> collectDescendants(String startId) { /// Everything under [spaceId], however deep, skipping any already
final visited = <String>{}; /// [visited] so cycles end.
final stack = [startId]; ISet<String> collectDescendants(
String spaceId, [
while (stack.isNotEmpty) { ISet<String> visited = const .empty(),
final current = stack.removeLast(); ]) => (childrenById[spaceId] ?? const []).fold(
final children = childrenById[current] ?? const []; visited,
(visited, childId) => visited.contains(childId)
for (final child in children) { ? visited
if (visited.add(child)) { : collectDescendants(childId, visited.add(childId)),
stack.add(child); );
}
}
}
return visited;
}
Space buildSpace(String spaceId) { Space buildSpace(String spaceId) {
final space = rooms[spaceId]; final space = rooms[spaceId];
final directChildrenIds = childrenById[spaceId] ?? const []; final directChildrenIds = childrenById[spaceId] ?? const [];
final directRooms = <Room>[];
final subSpaces = <Subspace>[];
for (final childId in directChildrenIds) {
final room = rooms[childId];
if (room == null) continue;
if (childrenById.containsKey(childId)) {
final descendants = collectDescendants(childId);
subSpaces.add(
.new(
room: room,
children: .new(descendants.map((id) => rooms[id]).nonNulls),
),
);
} else {
directRooms.add(room);
}
}
return .new( return .new(
id: spaceId, id: spaceId,
room: space, room: space,
title: space?.metadata?.name ?? "Unnamed Space", title: space?.metadata?.name ?? "Unnamed Space",
children: .new(directRooms), children: .new(
subSpaces: .new(subSpaces), directChildrenIds
.where((childId) => !childrenById.containsKey(childId))
.map((childId) => rooms[childId])
.nonNulls,
),
subSpaces: .new(
directChildrenIds
.where(childrenById.containsKey)
.map(
(childId) => switch (rooms[childId]) {
final room? => Subspace(
room: room,
children: .new(
collectDescendants(childId)
.map((id) => rooms[id])
.nonNulls,
),
),
null => null,
},
)
.nonNulls,
),
); );
} }

View file

@ -1,53 +1,48 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class ViaController(final Room room) extends Notifier<String> { class ViaController(final Room room) extends Notifier<String> {
@override @override
String build() { String build() {
final servers = <String>{}; final powerLevelUsers =
switch (room.state[EventType.powerLevels.type]?[""]) {
final rowId? => switch (room.events[rowId]?.content) {
PowerLevelsContent(:final users) => users.keys.toIList(),
_ => null,
},
null => null,
} ??
.new([]);
void addUserId(String? userId) { final joinedMembers =
final server = userId?.split(":").lastOrNull; (room.state[EventType.membership.type]?.values ?? const [])
if (server != null) { .map(
servers.add(server); (rowId) => switch (room.events[rowId]) {
} Event(
} stateKey: final userId?,
content: MembershipContent(status: .join),
) =>
userId,
_ => null,
},
)
.nonNulls
.toIList();
addUserId(ref.watch(ClientStateController.provider)?.userId); final servers = [?ref.watch(ClientStateController.provider)?.userId]
.toIList()
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; .addAll(powerLevelUsers)
final powerLevels = powerLevelsEventId == null .addAll(joinedMembers)
? null .map((userId) => userId.substring(userId.indexOf(":") + 1))
: room.events[powerLevelsEventId]; .whereNoDuplicates()
.take(5)
if (powerLevels?.content case PowerLevelsContent(:final users)) { .toISet();
for (final userId in users.keys) {
addUserId(userId);
if (servers.length >= 5) break;
}
}
final members = room.state[EventType.membership.type]?.values.toIList();
for (var i = 0; servers.length < 5; i++) {
final membershipEventId = members?.getOrNull(i);
final member = membershipEventId == null
? null
: room.events[membershipEventId];
if (member?.content case MembershipContent(:final status)) {
if (status == .join) {
addUserId(member?.stateKey);
}
}
if (members?.getOrNull(i) == null) break;
}
return servers.isEmpty return servers.isEmpty
? "" ? ""

View file

@ -18,6 +18,7 @@ import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/emoji_picker.dart"; import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
extension BuildEventOptions on Event { extension BuildEventOptions on Event {
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
mainAxisAlignment: isDesktop ? .center : .spaceEvenly, mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
children: [ children: [
...{ ...{
// Custom emoji are sent without their shortcode, as only
// their mxc URL is recorded
...ref ...ref
.read(RecentEmojiController.provider) .read(RecentEmojiController.provider)
.map((entry) => entry.emoji), .map((entry) => entry.emoji),
@ -109,10 +112,15 @@ extension BuildEventOptions on Event {
.take(isDesktop ? 3 : 5) .take(isDesktop ? 3 : 5)
.map( .map(
(emoji) => IconButton( (emoji) => IconButton(
icon: Text( icon: emoji.startsWith("mxc://")
emoji, ? SizedBox.square(
style: .new(fontSize: isDesktop ? null : 18), dimension: isDesktop ? 18 : 24,
), 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
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
context.showAdaptiveSheet( context.showAdaptiveSheet(
(context) => EmojiPicker( (context) => EmojiPicker(
roomId: roomId,
allowFreeText: true, allowFreeText: true,
onSelection: (emoji) { onSelection: (emoji, {shortcode, description}) {
Navigator.of(context).pop(); Navigator.of(context).pop();
ref ref
.read(notifier) .read(notifier)
.sendReaction(emoji, this) .sendReaction(emoji, this, shortcode: shortcode)
.onError(showError); .onError(showError);
}, },
), ),

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