Compare commits

...
Author SHA1 Message Date
e0b5dc58d0
gate android branches of window_manager 2026-10-22 17:31:43 -04:00
ffbf2722f7
turn down minimum size
too large for linux mobile rn
2026-10-22 16:33:02 -04:00
3e1552f246
don't open media if context not mounted 2026-10-22 16:09:27 -04:00
44a1ec667e
switch to new window_manager from main 2026-10-22 16:06:28 -04:00
479279b68e
bring back padding for filters on member list 2026-10-22 15:33:29 -04:00
b0dd452926
add power level room settings 2026-10-22 15:29:02 -04:00
fcfe4eed1e
more correct fallback for unknown join conditions 2026-10-22 14:32:58 -04:00
b390dbbbe1
add room access settings 2026-10-22 14:22:54 -04:00
8e51852ae3
add general room settings 2026-10-22 11:29:19 -04:00
48c04d9b3e
make swipe from right open member list 2026-10-22 10:53:07 -04:00
7f30150e95
make theme's platform Android when Linux Mobile Mode is on
Allows for swiping drawers closed
2026-10-22 10:37:31 -04:00
c8e1aef74b
show mutual rooms in user popover
Fixes #97
2026-10-22 10:25:50 -04:00
5ee3449f02
bump gomuks 2026-10-22 09:56:52 -04:00
1715ee785e
make names in typing indicators clickable 2026-10-21 23:01:22 -04:00
e38307657d
Read receipt support
Fixes #93
2026-10-21 22:58:06 -04:00
e14ab3e708
use *Async methods from gomuks
Performance improvement due to not needing as many Isolates.
2026-10-21 22:57:26 -04:00
f5645db6ea
only reserve space for jump to bottom when it exists 2026-10-21 15:09:41 -04:00
0fcbc275cd
add typing indicators
Fixes #92
2026-10-21 15:07:02 -04:00
a54004736b
add event search
Fixes #77
2026-10-21 13:31:01 -04:00
427e06be3e
add knock support
Fixes #123
2026-10-21 12:32:02 -04:00
a1a15af30d
use dot shorthands for default immutable collections where possible 2026-10-20 18:23:41 -04:00
800fb5566d add a global toggle to hide membership events (#142)
fixes #141

Co-authored-by: seres <seres@seres.eu.org>
Reviewed-on: #142
2026-10-20 18:15:01 -04:00
80197aff00
add keyboard inset workaround to MainActivity
Should be reverted once https://github.com/flutter/flutter/issues/191156 is fixed
2026-10-20 15:17:34 -04:00
78162efbde
move MainActivity to correct directory 2026-10-20 15:15:42 -04:00
104 changed files with 4244 additions and 916 deletions

View file

@ -1,5 +0,0 @@
package nexus.federated.nexus
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View file

@ -0,0 +1,40 @@
package nexus.federated.nexus
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsAnimation
import io.flutter.embedding.android.FlutterActivity
// TODO: Revert this commit once https://github.com/flutter/flutter/issues/191156 is fixed
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
window.decorView.setWindowInsetsAnimationCallback(
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(
insets: WindowInsets,
runningAnimations: List<WindowInsetsAnimation>,
) = insets
override fun onEnd(animation: WindowInsetsAnimation) {
if ((animation.typeMask and WindowInsets.Type.ime()) != 0) reapplyInsets()
}
},
)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) reapplyInsets()
}
// Posted so it runs after Flutter has applied any insets it deferred.
private fun reapplyInsets() {
val view = findViewById<View>(FLUTTER_VIEW_ID) ?: return
view.post { view.rootWindowInsets?.let(view::onApplyWindowInsets) }
}
}

2
gomuks

@ -1 +1 @@
Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310

View file

@ -8,6 +8,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/cupertino.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart";
@ -41,6 +42,7 @@ import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.dart";
import "package:nexus/models/requests/search.dart";
import "package:nexus/models/requests/send_event.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart";
@ -105,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
return handle;
}
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod(
Map<String, dynamic> data,
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
callback,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
late final NativeCallable<Void Function(GomuksResponse)> callable;
callable = .listener((GomuksResponse response) {
callable.close();
try {
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString());
}
completer.complete(json);
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
try {
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString());
}
return json;
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
call(handle, buffer, callable.nativeFunction);
} catch (_) {
// gomuks never got the call, so it won't respond or free the buffer
callable.close();
GomuksFreeBuffer(buffer);
rethrow;
}
return completer.future;
}
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
return response?["notification"] == null
? null
@ -147,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
Future<dynamic> _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data) {
]) => callGomuksMethod(data, (handle, data, callback) {
final commandPointer = command.toNativeUtf8().cast<Char>();
try {
return GomuksSubmitCommand(handle, commandPointer, data);
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
} finally {
// gomuks copies the command name before returning
calloc.free(commandPointer);
}
});
@ -222,6 +236,9 @@ class ClientController extends AsyncNotifier<int> {
Future<String> joinRoom(JoinRoomRequest request) async =>
(await _sendCommand("join_room", request.toJson()))["room_id"];
Future<String> knockRoom(JoinRoomRequest request) async =>
(await _sendCommand("knock_room", request.toJson()))["room_id"];
Future<String> createRoom(CreateRoomRequest request) async =>
(await _sendCommand("create_room", request.toJson()))["room_id"];
@ -273,6 +290,12 @@ class ClientController extends AsyncNotifier<int> {
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<PaginateManual> searchLocal(SearchRequest request) async =>
.fromJson(await _sendCommand("search_local", request.toJson()));
Future<PaginateManual> searchServer(SearchRequest request) async =>
.fromJson(await _sendCommand("search_server", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
@ -310,6 +333,11 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout");
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
"set_typing",
{"room_id": roomId, "timeout": timeout.inMilliseconds},
);
Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
@ -317,9 +345,14 @@ class ClientController extends AsyncNotifier<int> {
if (event == null) return;
// Private until settings have loaded, in case the user turned sending off
final sendPublic =
ref.read(SettingsController.provider).value?.readReceipts ==
.showAndSend;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
"receipt_type": "m.read",
"receipt_type": sendPublic ? "m.read" : "m.read.private",
"event_id": event.eventId,
});
}
@ -338,6 +371,34 @@ class ClientController extends AsyncNotifier<int> {
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
await _sendCommand("oauth_exchange_token", request.toJson());
Future<IList<String>> getMutualRooms(String userId) async => IList(
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
as List?)
?.cast<String>(),
);
Future<IList<String>> getLocalAliases(String roomId) async => IList(
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
as List?)
?.cast<String>(),
);
Future<void> createAlias(String roomId, String alias) =>
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
Future<void> deleteAlias(String alias) =>
_sendCommand("delete_alias", {"alias": alias});
/// The room [alias] points to, or null if it doesn't exist.
Future<String?> resolveAlias(String alias) async {
try {
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
} on GomuksException catch (error) {
if (error.statusCode == 404) return null;
rethrow;
}
}
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));

View file

@ -0,0 +1,97 @@
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/models/requests/search.dart";
typedef EventSearchParams = ({String roomId, String query});
typedef EventSearchResults = ({IList<int> rowIds, String? nextBatch});
class EventSearchController(final EventSearchParams info)
extends AsyncNotifier<EventSearchResults> {
// We could have a mode without this for power users in the future
static String toFtsQuery(String query) => query
.split(RegExp(r"\s+"))
.where((word) => word.isNotEmpty)
.map((word) => '"${word.replaceAll('"', '""')}"*')
.join(" ");
@override
Future<EventSearchResults> build() async {
if (info.query.trim().isEmpty) {
return (rowIds: IList<int>(), nextBatch: null);
}
final client = ref.read(ClientController.provider.notifier);
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return (rowIds: IList<int>(), nextBatch: null);
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
return (
rowIds: response.events.map((event) => event.rowId).toIList(),
nextBatch: response.nextBatch,
);
}
Future<void> paginate() async {
if (state.isLoading) return;
final results = await future;
if (!ref.mounted || results.nextBatch == null) return;
state = .loading();
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
nextBatch: results.nextBatch,
);
final client = ref.read(ClientController.provider.notifier);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return;
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
state = .data((
rowIds: results.rowIds.addAll(
response.events.map((event) => event.rowId),
),
nextBatch: response.nextBatch,
));
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
EventSearchController,
EventSearchResults,
EventSearchParams
>(EventSearchController.new);
}

View file

@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/controllers/typing.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:ffi/ffi.dart";
@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier<void> {
event.roomId: .new(events: .new({event.rowId: event})),
}),
);
case "typing":
ref
.read(TypingController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
case "events_decrypted":
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
final metadata = ref
@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
ref.invalidate(invitesProvider);
ref.invalidate(TypingController.provider);
}
ref
@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
if (paused) {
GomuksPauseSync(handle);
} else {
ref.invalidate(TypingController.provider);
GomuksResumeSync(handle);
}
}, fireImmediately: true);

View file

@ -0,0 +1,28 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
/// A room's aliases on the user's own server.
class LocalAliasesController(final String roomId)
extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() =>
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
Future<void> add(String alias) async {
await ref
.read(ClientController.provider.notifier)
.createAlias(roomId, alias);
ref.invalidateSelf();
}
Future<void> remove(String alias) async {
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
ref.invalidateSelf();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<LocalAliasesController, IList<String>, String>(
LocalAliasesController.new,
);
}

View file

@ -1,44 +1,27 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/room_creators.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/models/configs/members_by_status.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
class MembersGroupedController(final MembersByStatusConfig config)
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
@override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
final roomCreators = room == null
? null
: ref.watch((RoomCreatorsController.provider(room)));
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
final powerLevelsEvent = powerLevelsRowId == null
? null
: room?.events[powerLevelsRowId];
final content = switch (powerLevelsEvent?.content) {
PowerLevelsContent content => content,
_ => PowerLevelsContent(),
};
final members = await ref.watch(
MembersByStatusController.provider(config).future,
);
return members
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = roomCreators?.contains(event.stateKey!) == true
final groupKey = powerLevels.creators.contains(event.stateKey!)
? null
: content.users[event.stateKey!] ?? content.usersDefault;
: powerLevels.levelOf(event.stateKey!).toInt();
return result.update(
groupKey,

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
class MutualRoomsController(final String userId)
extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() =>
ref.read(ClientController.provider.notifier).getMutualRooms(userId);
static final provider = AsyncNotifierProvider.family
.autoDispose<MutualRoomsController, IList<String>, String>(
MutualRoomsController.new,
);
}

View file

@ -1,4 +1,3 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController(final NotificationsRequest request)
extends AsyncNotifier<IList<(int, String)>> {
void storeEvents(Iterable<Event> events) => ref
.read(RoomsController.provider.notifier)
.update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
.new(
events: IMap.fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
@override
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
@ -43,7 +22,9 @@ class NotificationsController(final NotificationsRequest request)
),
);
storeEvents(mentions.events.addAll(mentions.relatedEvents));
ref
.read(RoomsController.provider.notifier)
.storeEvents(mentions.events.addAll(mentions.relatedEvents));
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
}
@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request)
),
);
storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
ref
.read(RoomsController.provider.notifier)
.storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
return currentNotifications.addAll(
newNotifications.events.map((event) => (event.rowId, event.roomId)),

View file

@ -1,10 +1,8 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/room_creators.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/models/configs/power_level.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
class PowerLevelController(final PowerLevelConfig config)
extends Notifier<bool> {
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
);
}
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
final roomCreators = room == null
? null
: ref.watch(RoomCreatorsController.provider(room));
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
final event = eventRowId == null ? null : room?.events[eventRowId];
final content = event?.content is PowerLevelsContent
? event!.content
: PowerLevelsContent();
final content = powerLevels.content;
final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
if (user == null || content is! PowerLevelsContent) return false;
if (user == null) return false;
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
final userLevel = powerLevelOf(user);
final userLevel = powerLevels.levelOf(user);
return switch (config) {
EventPowerLevelConfig(:final eventType) =>
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
.invite => userLevel >= content.invite,
.kick =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
userLevel >= content.kick &&
userLevel > powerLevels.levelOf(targetUser),
.ban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
userLevel >= content.ban &&
userLevel > powerLevels.levelOf(targetUser),
.unban => userLevel >= content.ban,
},

View file

@ -0,0 +1,20 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
@override
bool build() => false;
Future<void> run(Future<void> Function() action) async {
state = true;
try {
await action();
} finally {
if (ref.mounted) state = false;
}
}
static final provider = NotifierProvider.autoDispose
.family<PowerLevelsSavingController, bool, String>(
PowerLevelsSavingController.new,
);
}

View file

@ -0,0 +1,90 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/models/read_receipt.dart";
/// Other users' read receipts in a chat, by the event ID of the message to
/// show them under: the nearest message at or before what they've read.
class ReadReceiptsController(final ChatParams chat)
extends Notifier<IMap<String, IList<ReadReceipt>>> {
@override
IMap<String, IList<ReadReceipt>> build() {
final shouldShow = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.readReceipts != .neither,
),
);
if (!shouldShow) return const .empty();
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
// The chat's own timeline, so receipts follow what's actually shown
final timeline = ref.watch(
RoomChatController.provider(chat)
.select((roomChat) => roomChat.value?.timeline),
);
final room = ref.watch(
RoomsController.provider.select(
(rooms) => switch (rooms[chat.roomId]) {
final room? => (events: room.events, receipts: room.receipts),
null => null,
},
),
);
if (userId == null || timeline == null || room == null) {
return const .empty();
}
final events = timeline.map((rowId) => room.events[rowId]).nonNulls;
final indexes = IMap.fromEntries(
events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)),
);
final messages = events.indexed
.where((entry) => entry.$2.isShownAsMessage)
.toIList();
final messageIndexes = messages.map((entry) => entry.$1).toList();
return room.receipts.values.flattened
.where(
(receipt) =>
receipt.userId != userId &&
// Receipts in threads are for the thread, not the timeline
receipt.thread == "main",
)
.map(
(receipt) => switch (indexes[receipt.eventId]) {
final index? => (
receipt: receipt,
message: lowerBound(messageIndexes, index + 1) - 1,
),
null => null,
},
)
.nonNulls
.where((entry) => entry.message >= 0)
.groupListsBy((entry) => messages[entry.message].$2.eventId)
.map(
(eventId, entries) => MapEntry(
eventId,
entries
.map((entry) => entry.receipt)
.sorted((a, b) => b.timestamp.compareTo(a.timestamp))
.toIList(),
),
)
.toIMap();
}
static final provider = NotifierProvider.family
.autoDispose<
ReadReceiptsController,
IMap<String, IList<ReadReceipt>>,
ChatParams
>(ReadReceiptsController.new);
}

View file

@ -73,16 +73,7 @@ class RoomChatController(final ChatParams info)
.addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
events.addAll(context.relatedEvents),
keyMapper: (event) => event.rowId,
),
),
}),
);
.storeEvents(events.addAll(context.relatedEvents));
return .new(
timeline: .new(events.map((element) => element.rowId)),
hasMoreBackward: true,
@ -165,6 +156,7 @@ class RoomChatController(final ChatParams info)
valueMapper: (event) => event,
),
hasMore: response.hasMore,
receipts: response.receipts,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
@ -186,17 +178,8 @@ class RoomChatController(final ChatParams info)
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
paginationResponse.events.addAll(
paginationResponse.relatedEvents,
),
keyMapper: (event) => event.rowId,
),
),
}),
.storeEvents(
paginationResponse.events.addAll(paginationResponse.relatedEvents),
);
final eventRowIds =

View file

@ -1,30 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/room.dart";
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
@override
IList<String> build() {
final createRowId = room.state[EventType.create.type]?[""];
final createEvent = createRowId == null ? null : room.events[createRowId];
if (createEvent == null) return .new();
final createEventContent = switch (createEvent.content) {
CreateContent content => content,
_ => null,
};
return switch (createEventContent?.additionalCreatorIds) {
IList<String> creators => creators.add(createEvent.sender),
_ => .new([createEvent.sender]),
};
}
static final provider =
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
RoomCreatorsController.new,
);
}

View file

@ -0,0 +1,59 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_power_levels.dart";
class RoomPowerLevelsController(final String roomId)
extends Notifier<RoomPowerLevels> {
@override
RoomPowerLevels build() {
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) {
final rowId? => room?.events[rowId],
null => null,
};
final createEvent = stateEvent(.create);
final create = switch (createEvent?.content) {
final CreateContent content => content,
_ => null,
};
// Creators only outrank everyone from room version 12, earlier ones just have a power level
final creatorsPrivileged =
(int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12;
return .new(
content: switch (stateEvent(.powerLevels)?.content) {
final PowerLevelsContent content => content,
// Until there are power levels, creators have 100
_ => PowerLevelsContent(
users: switch (createEvent) {
final createEvent? when !creatorsPrivileged => .new({
createEvent.sender: 100,
}),
_ => const .empty(),
},
),
},
creators: switch (createEvent) {
final createEvent? when creatorsPrivileged =>
(create?.additionalCreatorIds ?? const .empty())
.add(createEvent.sender)
.toISet(),
_ => const .empty(),
},
);
}
static final provider = NotifierProvider.autoDispose
.family<RoomPowerLevelsController, RoomPowerLevels, String>(
RoomPowerLevelsController.new,
);
}

View file

@ -0,0 +1,817 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/power_levels_saving.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/join_rule.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/local_aliases_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/text_setting_tile.dart";
class RoomSettingsSectionsController(final String roomId)
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
@override
Future<IMap<String, IList<SettingsCategory>>> build() async {
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
final client = ref.read(ClientController.provider.notifier);
final roomsController = ref.read(RoomsController.provider.notifier);
// The room's own state, rather than metadata like a name made up from
// its members when it has none
Content? stateContent(EventType type) =>
switch (room?.state[type.type]?[""]) {
final rowId? => room?.events[rowId]?.content,
null => null,
};
final name = switch (stateContent(.name)) {
NameContent(:final name) => name,
_ => null,
};
final topic = switch (stateContent(.topic)) {
TopicContent(:final topic) => topic,
_ => room?.metadata?.topic,
};
final avatar = switch (stateContent(.avatar)) {
AvatarContent(:final url) => url,
_ => room?.metadata?.avatar,
};
bool canSet(EventType type) => ref.watch(
PowerLevelController.provider(.state(eventType: type, roomId: roomId)),
);
final canSetAvatar = canSet(.avatar);
final canSetName = canSet(.name);
final canSetTopic = canSet(.topic);
final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias);
final canSetPowerLevels =
canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId));
final joinRules = switch (stateContent(.joinRules)) {
final JoinRulesContent content => content,
_ => JoinRulesContent(joinRule: .invite),
};
final historyVisibility = switch (stateContent(.historyVisibility)) {
HistoryVisibilityContent(:final historyVisibility) => historyVisibility,
_ => HistoryVisibility.shared,
};
final canonicalAlias = switch (stateContent(.canonicalAlias)) {
final CanonicalAliasContent content => content,
_ => CanonicalAliasContent(),
};
final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]);
final localAliases =
ref.watch(LocalAliasesController.provider(roomId)).value ??
const IList<String>.empty();
final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([
?canonicalAlias.alias,
]).toISet();
final roomVersion = switch (stateContent(.create)) {
CreateContent(:final roomVersion) => int.tryParse(roomVersion),
_ => null,
};
bool versionAtLeast(int version) =>
roomVersion == null || roomVersion >= version;
final joinRuleOptions = [
JoinRule.public,
JoinRule.invite,
if (versionAtLeast(7)) JoinRule.knock,
if (versionAtLeast(8)) JoinRule.restricted,
if (versionAtLeast(10)) JoinRule.knockRestricted,
];
bool isRestricted(JoinRule rule) =>
rule == .restricted || rule == .knockRestricted;
final parentSpaces = ref
.watch(
SpaceEdgesController.provider.select(
(edges) => edges.entries
.where(
(entry) => entry.value.any((edge) => edge.childId == roomId),
)
.map((entry) => entry.key)
.toIList(),
),
)
.addAll(room?.state["m.space.parent"]?.keys ?? const [])
.toISet();
final allowedRooms = joinRules.allow
.map((condition) => condition.roomId)
.nonNulls
.toISet();
final roomPowerLevels = ref.watch(
RoomPowerLevelsController.provider(roomId),
);
final powerLevels = roomPowerLevels.content;
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them
final messageType = stateContent(.encryption) is EncryptionContent
? EventType.encrypted
: EventType.message;
Future<void> setState(EventType type, Content content) async {
final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
);
if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId);
}
}
Future<void> setAliases(
String? canonical,
ISet<String> alternatives,
) async {
final stale = (await Future.wait(
alternatives.addAll([?canonical]).map((alias) async {
try {
return await client.resolveAlias(alias) == roomId ? null : alias;
} catch (_) {
return null;
}
}),
)).nonNulls.toISet();
bool kept(String alias) => !stale.contains(alias);
await setState(
.canonicalAlias,
CanonicalAliasContent(
alias: canonical != null && kept(canonical) ? canonical : null,
altAliases: alternatives.where(kept).toISet(),
),
);
}
Future<void> setAllowedRooms(ISet<String> roomIds) => setState(
.joinRules,
joinRules.copyWith(
allow: roomIds
.map((roomId) => AllowCondition(roomId: roomId, type: .membership))
.toISet(),
),
);
Setting header(String title, String description, IconData icon) => .new(
title: title,
description: description,
icon: icon,
builder: (title, _, _) => Padding(
padding: .symmetric(horizontal: 16),
child: DividerText(title),
),
);
Future<void> setPowerLevels(PowerLevelsContent content) => ref
.read(PowerLevelsSavingController.provider(roomId).notifier)
.run(() => setState(.powerLevels, content).onError(showError));
// Event types with their own level that don't have a setting of their own
final listedEvents = [
messageType,
EventType.sticker,
EventType.reaction,
EventType.pinnedEvents,
].map((type) => type.type).toISet();
final otherEvents = powerLevels.events.entries
.where((entry) => !listedEvents.contains(entry.key))
.toIList()
.sort((a, b) => a.key.compareTo(b.key));
// Users with their own level, the highest first
final users = powerLevels.users.entries.toIList().sort(
(a, b) => switch (b.value.compareTo(a.value)) {
0 => a.key.compareTo(b.key),
final order => order,
},
);
MembershipContent? memberOf(String userId) =>
switch (room?.state[EventType.membership.type]?[userId]) {
final rowId? => switch (room?.events[rowId]?.content) {
final MembershipContent content => content,
_ => null,
},
null => null,
};
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
Setting addSetting({
required String title,
required String description,
required Widget Function(void Function(PowerLevelsContent content) save)
sheet,
}) => .new(
title: title,
description: description,
icon: Icons.add,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
enabled: canSetPowerLevels,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
),
),
);
Setting powerLevelSetting({
required String title,
required String description,
required IconData icon,
required int value,
required PowerLevelsContent Function(int level) update,
}) => .new(
title: title,
description: description,
icon: icon,
builder: (title, description, icon) => PowerLevelTile(
leading: Icon(icon),
title: title,
description: description,
value: value,
ownLevel: ownLevel,
onChanged: canSetPowerLevels
? (level) => setPowerLevels(update(level))
: null,
),
);
/// A setting for the level needed to send [type], which otherwise needs [fallback].
Setting eventSetting(
EventType type, {
required String title,
required String description,
required IconData icon,
required int fallback,
}) => powerLevelSetting(
title: title,
description: description,
icon: icon,
value: powerLevels.events[type.type] ?? fallback,
update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(type.type, level),
),
);
return .new({
"Room": .new([
.new(
title: "General",
icon: Icons.tune,
settings: .new([
.new(
title: "Avatar",
description: "The image shown for the room.",
icon: Icons.image,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
Future<void> save(Future<void> Function() action) =>
saving.run(() => action().onError(showError));
return ListTile(
enabled: canSetAvatar && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
trailing: Row(
mainAxisSize: .min,
spacing: 8,
children: [
AvatarOrHash(
avatar,
name ?? "Unnamed Room",
dimension: 40,
fallback: Icon(Icons.numbers),
),
if (avatar != null && canSetAvatar)
IconButton(
tooltip: "Remove avatar",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => save(
() => setState(.avatar, const .new()),
),
),
],
),
onTap: () async {
final file = await ProviderScope.containerOf(context)
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await save(() async {
final content = await client.uploadMedia(
.new(path: file.path, encrypt: false),
);
if (content case ImageMessageContent(
:final url,
:final info,
)) {
await setState(
.avatar,
AvatarContent(url: url, info: info),
);
} else {
throw Exception("The file isn't an image");
}
});
},
);
},
),
),
.new(
title: "Room Name",
description: "The name shown for the room.",
icon: Icons.title,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: name,
onChanged: canSetName
? (value) => setState(
.name,
NameContent(name: value),
).onError(showError)
: null,
),
),
.new(
title: "Topic",
description: "A short description of the room.",
icon: Icons.info,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: topic,
multiline: true,
onChanged: canSetTopic
? (value) => setState(
.topic,
TopicContent(
topic: value,
content: .new(
representations: .new([.new(body: value)]),
),
),
).onError(showError)
: null,
),
),
]),
),
.new(
title: "Access",
icon: Icons.key,
settings: .new([
.new(
title: "Join Rule",
description: "Who can join the room.",
icon: Icons.login,
builder: (title, description, icon) => DialogListTile<JoinRule>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: joinRules.joinRule,
options: joinRuleOptions,
getName: (rule) => switch (rule) {
.public => "Public",
.invite => "Invite only",
.knock => "Knock",
.restricted => "Restricted",
.knockRestricted => "Restricted or knock",
.private => "Private",
},
onChanged: canSetJoinRules
? (rule) => setState(
.joinRules,
JoinRulesContent(
joinRule: rule,
allow: isRestricted(rule) && joinRules.allow.isEmpty
? .new(
parentSpaces.map(
(roomId) =>
.new(roomId: roomId, type: .membership),
),
)
: joinRules.allow,
),
).onError(showError)
: null,
),
),
if (isRestricted(joinRules.joinRule))
.new(
title: "Allowed Rooms",
description:
"Members of these rooms can join without an invite.",
icon: Icons.meeting_room,
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final saving = Saving.use();
final names = ref.watch(
RoomsController.provider.select(
(rooms) => allowedRooms
.map(
(roomId) =>
rooms[roomId]?.metadata?.name ?? roomId,
)
.toIList(),
),
);
return ListTile(
enabled: canSetJoinRules && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
names.isEmpty
? "None, so only invited users can join"
: names.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AllowedRoomsSheet(
roomId: roomId,
allowed: allowedRooms,
parentSpaces: parentSpaces,
onSave: (allowed) => saving.run(
() => setAllowedRooms(allowed).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Aliases",
description:
"The addresses the room advertises, and its main one.",
icon: Icons.alternate_email,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canSetAliases && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
switch ((
canonicalAlias.alias,
canonicalAlias.altAliases.length,
)) {
(null, 0) => description,
(final alias?, 0) => alias,
(final alias?, 1) =>
"$alias and ${canonicalAlias.altAliases.first}",
(final alias?, final others) =>
"$alias and $others more",
(null, _) => canonicalAlias.altAliases.join(", "),
},
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AliasesSheet(
aliases: knownAliases,
canonical: canonicalAlias.alias,
alternatives: canonicalAlias.altAliases,
onSave: (canonical, alternatives) => saving.run(
() => setAliases(
canonical,
alternatives,
).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Local Aliases",
description: "Addresses for the room on your server.",
icon: Icons.dns,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(
localAliases.isEmpty
? description
: localAliases.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => LocalAliasesSheet(
roomId,
onRemoved: canSetAliases
? (alias) async {
if (published.contains(alias)) {
await setAliases(
canonicalAlias.alias,
canonicalAlias.altAliases,
);
}
}
: null,
),
),
),
),
),
.new(
title: "History Visibility",
description:
"Which messages from before joining members can read.",
icon: Icons.history,
builder: (title, description, icon) =>
DialogListTile<HistoryVisibility>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: historyVisibility,
options: [.shared, .invited, .joined],
getName: (visibility) => switch (visibility) {
.shared => "All events",
.invited => "After invite",
.joined => "After join",
.worldReadable => "Anyone",
},
onChanged: canSetHistoryVisibility
? (visibility) => setState(
.historyVisibility,
HistoryVisibilityContent(
historyVisibility: visibility,
),
).onError(showError)
: null,
),
),
]),
),
.new(
title: "Permissions",
icon: Icons.admin_panel_settings,
settings: .new([
powerLevelSetting(
title: "Default Power Level",
description: "The power level of users who aren't given one.",
icon: Icons.person,
value: powerLevels.usersDefault,
update: (level) => powerLevels.copyWith(usersDefault: level),
),
header("Users", "Power levels of specific users.", Icons.group),
...users.map((entry) {
final member = memberOf(entry.key);
// Others can only be changed if they're below you, but your own level can be lowered
final canChange =
canSetPowerLevels &&
(entry.key == userId || entry.value < ownLevel);
return Setting(
title: member?.displayName ?? entry.key,
description: entry.key,
icon: Icons.person,
builder: (title, description, _) => PowerLevelTile(
leading: AvatarOrHash(
member?.avatarUrl,
title,
dimension: 40,
),
title: title,
description: description,
value: entry.value,
ownLevel: ownLevel,
onChanged: canChange
? (level) => setPowerLevels(
powerLevels.copyWith(
users: powerLevels.users.add(entry.key, level),
),
)
: null,
onRemoved: canChange
? () => setPowerLevels(
powerLevels.copyWith(
users: powerLevels.users.remove(entry.key),
),
)
: null,
removeConfirmation: entry.key == userId
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
),
);
}),
addSetting(
title: "Add Users",
description: "Give users their own power level.",
sheet: (save) => AddUsersPowerLevelSheet(
roomId: roomId,
existing: powerLevels.users.keys.toISet().union(
roomPowerLevels.creators,
),
ownLevel: ownLevel,
onSave: (users, level) => save(
powerLevels.copyWith(
users: powerLevels.users.addEntries(
users.map((user) => MapEntry(user, level)),
),
),
),
),
),
header(
"Regular Events",
"Power levels needed to send messages and other timeline events.",
Icons.chat,
),
eventSetting(
messageType,
title: "Send Messages",
description: "The power level needed to send messages.",
icon: Icons.message,
fallback: powerLevels.eventsDefault,
),
eventSetting(
.sticker,
title: "Send Stickers",
description: "The power level needed to send stickers.",
icon: Icons.sticky_note_2,
fallback: powerLevels.eventsDefault,
),
eventSetting(
.reaction,
title: "Send Reactions",
description: "The power level needed to react to messages.",
icon: Icons.add_reaction,
fallback: powerLevels.eventsDefault,
),
powerLevelSetting(
title: "Default for Events",
description:
"The power level needed to send events without their own.",
icon: Icons.more_horiz,
value: powerLevels.eventsDefault,
update: (level) => powerLevels.copyWith(eventsDefault: level),
),
header(
"State Events",
"Power levels needed to manage members and change the room's state.",
Icons.settings,
),
eventSetting(
.pinnedEvents,
title: "Pin Messages",
description: "The power level needed to pin messages.",
icon: Icons.push_pin,
fallback: powerLevels.stateDefault,
),
powerLevelSetting(
title: "Invite Users",
description: "The power level needed to invite users.",
icon: Icons.person_add,
value: powerLevels.invite,
update: (level) => powerLevels.copyWith(invite: level),
),
powerLevelSetting(
title: "Kick Users",
description: "The power level needed to kick users.",
icon: Icons.sports_martial_arts,
value: powerLevels.kick,
update: (level) => powerLevels.copyWith(kick: level),
),
powerLevelSetting(
title: "Ban Users",
description: "The power level needed to ban and unban users.",
icon: Icons.gavel,
value: powerLevels.ban,
update: (level) => powerLevels.copyWith(ban: level),
),
powerLevelSetting(
title: "Delete Others' Messages",
description: "The power level needed to remove others' messages.",
icon: Icons.delete,
value: powerLevels.redact,
update: (level) => powerLevels.copyWith(redact: level),
),
powerLevelSetting(
title: "Default for State Events",
description: "The power level needed to send state events without their own.",
icon: Icons.tune,
value: powerLevels.stateDefault,
update: (level) => powerLevels.copyWith(stateDefault: level),
),
header(
"Other Events",
"Power levels for specific event types.",
Icons.code,
),
...otherEvents.map(
(entry) => Setting(
title: entry.key,
description: "The power level needed to send this event.",
icon: Icons.code,
builder: (title, _, icon) => PowerLevelTile(
leading: Icon(icon),
title: title,
description: null,
value: entry.value,
ownLevel: ownLevel,
onChanged: canSetPowerLevels
? (level) => setPowerLevels(
powerLevels.copyWith(
events: powerLevels.events.add(entry.key, level),
),
)
: null,
onRemoved: canSetPowerLevels
? () => setPowerLevels(
powerLevels.copyWith(
events: powerLevels.events.remove(entry.key),
),
)
: null,
),
),
),
addSetting(
title: "Add Event",
description: "Give an event type its own power level.",
sheet: (save) => PowerLevelEntrySheet(
title: "Add Event",
keyLabel: "Event type",
keyHint: "m.room.name",
ownLevel: ownLevel,
onSave: (type, level) => save(
powerLevels.copyWith(
events: powerLevels.events.add(type, level),
),
),
),
),
]),
),
]),
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
RoomSettingsSectionsController,
IMap<String, IList<SettingsCategory>>,
String
>(RoomSettingsSectionsController.new);
}

View file

@ -0,0 +1,34 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/typing.dart";
/// The other users typing in a room, or none if typing indicators are hidden
/// in settings.
class RoomTypingController(final String roomId)
extends Notifier<IList<String>> {
@override
IList<String> build() {
final shouldShow = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.typingIndicators != .neither,
),
);
if (!shouldShow) return const .empty();
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
return ref
.watch(TypingController.provider.select((typing) => typing[roomId]))
?.remove(userId ?? "") ??
const .empty();
}
static final provider = NotifierProvider.family
.autoDispose<RoomTypingController, IList<String>, String>(
RoomTypingController.new,
);
}

View file

@ -1,7 +1,10 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> {
@ -33,9 +36,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(),
);
static IMap<String, IList<ReadReceipt>> mergeReceipts(
IMap<String, IList<ReadReceipt>> existing,
IMap<String, IList<ReadReceipt>> incoming,
) => incoming.isEmpty
? existing
: existing.values.flattened
.followedBy(incoming.values.flattened)
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
.values
.map(
(receipts) => receipts.reduce(
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
? newest
: receipt,
),
)
.groupListsBy((receipt) => receipt.eventId)
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
.toIMap();
/// Adds events to their rooms, without changing the rooms' timelines.
void storeEvents(Iterable<Event> events) => update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
Room(
// ignore: unused_result - I am using it you donkey
events: .fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const ISet.empty(),
ISet<String> leftRooms = const .empty(),
]) {
final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
@ -76,21 +119,42 @@ class RoomsController extends Notifier<IMap<String, Room>> {
timeline: incoming.reset
? incoming.timeline
: existing.timeline.addAll(incoming.timeline),
receipts: incoming.receipts.entries.fold(
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value),
),
),
receipts: mergeReceipts(existing.receipts, incoming.receipts),
) ??
incoming,
incoming.copyWith(
receipts: mergeReceipts(const .empty(), incoming.receipts),
),
);
});
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
}
/// Waits for a sent state event to become the room's state, which happens
/// once it comes down sync. Gives up after a while, e.g. if another event
/// replaced it first.
Future<void> awaitState(
String roomId,
String type,
String eventId, {
String stateKey = "",
}) {
bool arrived(IMap<String, Room> rooms) =>
switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false,
};
if (arrived(state)) return .value();
final completer = Completer<void>();
final removeListener = listenSelf((_, rooms) {
if (arrived(rooms) && !completer.isCompleted) completer.complete();
});
return completer.future
.timeout(const .new(seconds: 10), onTimeout: () {})
.whenComplete(removeListener);
}
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
RoomsController.new,
);

View file

@ -19,7 +19,9 @@ import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/models/room_order.dart";
import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart";
@ -31,6 +33,30 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future);
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
Widget showAndSendTile(
String title,
String description,
IconData icon, {
required ShowAndSend value,
required Settings Function(ShowAndSend value) update,
}) => DialogListTile<ShowAndSend>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: value,
options: ShowAndSend.values,
getName: (option) => switch (option) {
.showAndSend => "Show and send",
.show => "Only show",
.neither => "Neither",
},
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(update(value))
.onError(showError),
);
return .new({
"General": .new([
.new(
@ -151,7 +177,7 @@ class SettingsSectionsController
),
.new(
title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.",
icon: Icons.construction,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
@ -166,6 +192,51 @@ class SettingsSectionsController
: null,
),
),
.new(
title: "Hide Membership Events",
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
icon: Icons.person_off,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.hideMembershipEvents,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(hideMembershipEvents: value))
.onError(showError),
),
),
]),
),
.new(
title: "Privacy",
icon: Icons.privacy_tip,
settings: .new([
.new(
title: "Typing Indicators",
description: "Show when others are typing, and let others see when you're typing.",
icon: Icons.keyboard,
builder: (title, description, icon) => showAndSendTile(
title,
description,
icon,
value: settings.typingIndicators,
update: (value) => settings.copyWith(typingIndicators: value),
),
),
.new(
title: "Read Receipts",
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
icon: Icons.done_all,
builder: (title, description, icon) => showAndSendTile(
title,
description,
icon,
value: settings.readReceipts,
update: (value) => settings.copyWith(readReceipts: value),
),
),
]),
),
]),

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/typing.dart";
class TypingController extends Notifier<IMap<String, IList<String>>> {
@override
IMap<String, IList<String>> build() => .new();
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
static final provider =
NotifierProvider<TypingController, IMap<String, IList<String>>>(
TypingController.new,
);
}

View file

@ -88,7 +88,8 @@ class UnifiedPushController extends AsyncNotifier<String?> {
if (notification == null ||
(!isInBackground &&
await windowManager.isFocused().onError((_, _) => true) &&
(!Platform.isAndroid &&
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
ref.read(SelectedRoomController.provider) ==
notification.roomId)) {
if (isInBackground) exit(0);

View file

@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
required Widget Function(T value) data,
Widget Function() loading = Loading.new,
bool skipLoadingOnRefresh = false,
bool skipLoadingOnReload = false,
}) => when(
data: data,
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
loading: loading,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipLoadingOnReload: skipLoadingOnReload,
);
}

View file

@ -19,19 +19,21 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
}
extension JsonToGomuksBuffer on Map<String, dynamic> {
GomuksOwnedBuffer toGomuksOwnedBuffer() {
final bytes = utf8.encode(json.encode(this));
final buffer = GomuksAllocBuffer(bytes.length);
buffer.base.asTypedList(bytes.length).setAll(0, bytes);
return buffer;
}
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
final jsonString = json.encode(this);
final bytes = utf8.encode(jsonString);
final bytes = utf8.encode(json.encode(this));
final dataPtr = calloc<Uint8>(bytes.length);
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
final ptr = calloc<GomuksBorrowedBuffer>();
ptr.ref
..base = dataPtr
..length = bytes.length;
return ptr;
return calloc<GomuksBorrowedBuffer>()
..ref.base = dataPtr
..ref.length = bytes.length;
}
}

View file

@ -0,0 +1,15 @@
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/event.dart";
extension IsShownAsMessage on Event {
/// Whether this is shown in the timeline as a message, with MessageRenderer.
bool get isShownAsMessage =>
redactedBy == null &&
relationType != "m.replace" &&
switch (content) {
MessageContent() || EncryptedContent() || StickerContent() => true,
_ => false,
};
}

View file

@ -40,4 +40,29 @@ extension JoinRoom on BuildContext {
return roomId;
}
/// Knocks on a room, success and errors in snackbars.
/// Returns the room's ID, or null if knocking failed.
Future<String?> knockRoom(
JoinRoomRequest request, {
required String name,
}) async {
final container = ProviderScope.containerOf(this);
final messenger = ScaffoldMessenger.of(this);
final roomId = await withProgressSnackbar(
"Knocking on $name...",
() =>
container.read(ClientController.provider.notifier).knockRoom(request),
);
if (roomId == null) return null;
messenger.showSnackBar(
.new(
content: Text("Knocked on $name. Wait for a moderator to let you in."),
),
);
return roomId;
}
}

View file

@ -1,14 +1,8 @@
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_popover.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
extension ShowUserPopover on BuildContext {
void showUserPopover(
MembershipContent member,
String userId, {
String? roomId,
}) => showAdaptivePopover(
(context) => UserBottomSheet(member, userId, roomId: roomId),
);
void showUserPopover(String userId, {String? roomId}) =>
showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId));
}

View file

@ -0,0 +1,25 @@
import "dart:async";
import "package:flutter_hooks/flutter_hooks.dart";
final class Saving({
required final bool active,
required final Future<void> Function(FutureOr<void> Function() action) run,
}) {
factory use() {
final context = useContext();
final active = useState(false);
return .new(
active: active.value,
run: (action) async {
active.value = true;
try {
await action();
} finally {
if (context.mounted) active.value = false;
}
},
);
}
}

View file

@ -83,14 +83,10 @@ void main(List<String> args) async {
exit(0);
} else {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow(
WindowOptions(
titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
WindowManager.instance.getCurrent()
?..titleBarStyle = .hidden
..isWindowControlButtonsVisible = false
..minimumSize = Size.square(300).toNative();
}
runApp(
@ -111,10 +107,21 @@ class const App({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => Consumer(
builder: (context, ref, child) => MaterialApp(
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
theme:
builder: (context, ref, child) {
// Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android
ThemeData forPlatform(ThemeData theme) =>
ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.linuxMobileMode ?? false,
),
)
? theme.copyWith(platform: .android)
: theme;
return MaterialApp(
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
theme: forPlatform(
(ref
.watch(SettingsController.provider)
.maybeWhen(
@ -124,7 +131,8 @@ class const App({super.key}) extends StatelessWidget {
) ??
ThemeData.light().colorScheme)
.theme,
darkTheme:
),
darkTheme: forPlatform(
(ref
.watch(SettingsController.provider)
.maybeWhen(
@ -134,14 +142,16 @@ class const App({super.key}) extends StatelessWidget {
) ??
ThemeData.dark().colorScheme)
.theme,
themeMode: ref
.watch(SettingsController.provider)
.maybeWhen(
data: (settings) => settings.theme,
orElse: () => ThemeMode.system,
),
home: child,
),
),
themeMode: ref
.watch(SettingsController.provider)
.maybeWhen(
data: (settings) => settings.theme,
orElse: () => ThemeMode.system,
),
home: child,
);
},
child: DefaultTextHeightBehavior(
textHeightBehavior: .new(leadingDistribution: .even),
child: Scaffold(

View file

@ -12,7 +12,7 @@ class const AccountData({
const InvitePermissionConfig(),
@JsonKey(name: AccountData.directKey)
final IMap<String, IList<String>> directMessages = const IMap.empty(),
final IMap<String, IList<String>> directMessages = const .empty(),
@JsonKey(name: AccountData.recentEmojiKey)
final RecentEmojiData recentEmoji = const RecentEmojiData(),
@ -30,7 +30,7 @@ class const AccountData({
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RecentEmojiData({
final IList<RecentEmoji> recentEmoji = const IList.empty(),
final IList<RecentEmoji> recentEmoji = const .empty(),
}) with _$RecentEmojiData {
Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);

View file

@ -9,7 +9,7 @@ sealed class CanonicalAliasContent extends Content
with _$CanonicalAliasContent {
CanonicalAliasContent._();
factory CanonicalAliasContent({
String? alias,
@JsonKey(includeIfNull: false) String? alias,
@Default(ISet.empty()) ISet<String> altAliases,
}) = _CanonicalAliasContent;

View file

@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
JoinRulesContent._();
factory JoinRulesContent({
required JoinRule joinRule,
@Default(IList.empty()) IList<AllowCondition> allow,
@Default(ISet.empty()) ISet<AllowCondition> allow,
}) = _JoinRulesContent;
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
@ -21,6 +21,8 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
@JsonSerializable()
class const AllowCondition({
final String? roomId,
@JsonKey(unknownEnumValue: AllowConditionType.unknown)
required final AllowConditionType type,
}) with _$AllowCondition {
Map<String, Object?> toJson() => _$AllowConditionToJson(this);
@ -32,4 +34,5 @@ class const AllowCondition({
enum AllowConditionType {
@JsonValue("m.room_membership")
membership,
unknown,
}

View file

@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
factory PowerLevelsContent({
@Default(IMap.empty()) IMap<String, int> events,
@Default(IMap.empty()) IMap<String, int> users,
Notifications? notifications,
@JsonKey(includeIfNull: false) Notifications? notifications,
@Default(50) int ban,
@Default(0) int eventsDefault,
@Default(0) int invite,
@ -28,7 +28,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
@JsonSerializable()
class const Notifications({
final int room = 50,
final IMap<String, int> other = const IMap.empty(),
final IMap<String, int> other = const .empty(),
}) with _$Notifications {
Map<String, Object?> toJson() => _$NotificationsToJson(this);

View file

@ -18,7 +18,7 @@ class const Event({
required final String sender,
@JsonKey(readValue: Event.typeJsonFromJson) required final String type,
@EpochDateTimeConverter() required final DateTime timestamp,
final IMap<String, dynamic> unsigned = const IMap.empty(),
final IMap<String, dynamic> unsigned = const .empty(),
final LocalContent? localContent,
final String? transactionId,
final String? redactedBy,
@ -27,7 +27,7 @@ class const Event({
final String? replyTo,
final String? decryptionError,
final String? sendError,
final IMap<String, int> reactions = const IMap.empty(),
final IMap<String, int> reactions = const .empty(),
@JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0,
final UnreadType? unreadType,
final Profile? pmp,

View file

@ -13,7 +13,7 @@ class const EventContext({
required final IList<Event> after,
required final String start,
required final String end,
final IList<Event> relatedEvents = const IList.empty(),
final IList<Event> relatedEvents = const .empty(),
}) with _$EventContext {
Map<String, Object?> toJson() => _$EventContextToJson(this);

View file

@ -6,8 +6,8 @@ part "image.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ImageInfo({
@JsonKey(name: "h") final double? height,
@JsonKey(name: "w") final double? width,
@JsonKey(name: "h") final int? height,
@JsonKey(name: "w") final int? width,
@JsonKey(name: "mimetype") final String? mimeType,
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
final int? size,

View file

@ -1,6 +1,7 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
part "paginate.freezed.dart";
part "paginate.g.dart";
@ -10,6 +11,9 @@ part "paginate.g.dart";
class const Paginate({
required final IList<Event> events,
required final IList<Event> relatedEvents,
/// Read receipts for the events, by event ID.
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
required final bool hasMore,
}) with _$Paginate {
Map<String, Object?> toJson() => _$PaginateToJson(this);

View file

@ -8,8 +8,8 @@ part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManual({
required final IList<Event> events,
final IList<Event> relatedEvents = const IList.empty(),
final IList<Event> events = const .empty(),
final IList<Event> relatedEvents = const .empty(),
required final String? nextBatch,
}) with _$PaginateManual {
Map<String, Object?> toJson() => _$PaginateManualToJson(this);

View file

@ -40,7 +40,7 @@ class const Profile({
final String? timezone,
@JsonKey(readValue: Profile.readPronouns, name: "m.pronouns")
final IList<Pronoun> pronouns = const IList.empty(),
final IList<Pronoun> pronouns = const .empty(),
}) with _$Profile {
Map<String, Object?> toJson() => _$ProfileToJson(this);

View file

@ -13,6 +13,11 @@ class const ReadReceipt({
required final String eventId,
@EpochDateTimeConverter() required final DateTime timestamp,
}) with _$ReadReceipt {
String get thread => switch (threadId) {
final threadId? when threadId.isNotEmpty => threadId,
_ => "main",
};
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
factory ReadReceipt.fromJson(Map<String, Object?> json) =>

View file

@ -13,10 +13,10 @@ class const CreateRoomRequest({
final String? roomAliasName,
final String? name,
final String? topic,
final IList<String> invite = const IList.empty(),
final IList<String> invite = const .empty(),
@JsonKey(name: "uk.timedout.msc4491.invite_reason")
final String? inviteReason,
final IList<InitialStateEvent> initialState = const IList.empty(),
final IList<InitialStateEvent> initialState = const .empty(),
final RoomPreset? preset,
final bool isDirect = false,
final IMap<String, dynamic>? creationContent,

View file

@ -8,7 +8,8 @@ part "join_room.g.dart";
@JsonSerializable()
class const JoinRoomRequest({
required final String roomIdOrAlias,
final IList<String> via = const IList.empty(),
final IList<String> via = const .empty(),
final String? reason,
/// Set when accepting an invite, so gomuks can mark DMs as such.
final bool fromInvite = false,

View file

@ -0,0 +1,21 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "search.freezed.dart";
part "search.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(includeIfNull: false)
class const SearchRequest({
required final String searchTerm,
final IList<String> roomIds = const .empty(),
final int limit = 20,
final bool sortByTime = true,
final String? nextBatch,
}) with _$SearchRequest {
Map<String, Object?> toJson() => _$SearchRequestToJson(this);
factory SearchRequest.fromJson(Map<String, Object?> json) =>
_$SearchRequestFromJson(json);
}

View file

@ -25,7 +25,7 @@ class const SendMessageRequest({
@JsonSerializable()
class const Mentions({
final bool room = false,
final IList<String> userIds = const IList.empty(),
final IList<String> userIds = const .empty(),
}) with _$Mentions {
Map<String, Object?> toJson() => _$MentionsToJson(this);

View file

@ -14,15 +14,15 @@ class const Room({
/// [timeline] is an IMap of timelineRowId to eventRowId
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
final IMap<int, int?> timeline = const IMap.empty(),
final IMap<int, int?> timeline = const .empty(),
/// [clientSticky] is an ISet of eventRowId
@JsonKey(includeFromJson: false, includeToJson: false)
final ISet<int> clientSticky = const ISet.empty(),
final ISet<int> clientSticky = const .empty(),
/// [events] is an IMap of eventRowId to event
@JsonKey(fromJson: Room.eventsJsonToIMap)
final IMap<int, Event> events = const IMap.empty(),
final IMap<int, Event> events = const .empty(),
final bool reset = false,
@JsonKey(includeFromJson: false, includeToJson: false)
@ -34,9 +34,9 @@ class const Room({
@JsonKey(includeFromJson: false, includeToJson: false)
final bool hasFetchedMembers = false,
final IMap<String, IMap<String, int>> state = const IMap.empty(),
final IMap<String, IMap<String, int>> state = const .empty(),
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
final bool dismissNotifications = false,
final bool hasMore = true,

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
import "package:nexus/models/lazy_load_summary.dart";
@ -15,6 +16,8 @@ class const RoomMetadata({
final String? topic,
final String? canonicalAlias,
final LazyLoadSummary? lazyLoadSummary,
final EncryptionContent? encryptionEvent,
required final bool hasMemberList,
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
@EpochDateTimeConverter() required final DateTime sortingTimestamp,

View file

@ -0,0 +1,11 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/content/power_levels.dart";
class const RoomPowerLevels({
required final PowerLevelsContent content,
final ISet<String> creators = const .empty(),
}) {
double levelOf(String userId) => creators.contains(userId)
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
}

View file

@ -1,3 +1,4 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/membership.dart";
@ -14,6 +15,9 @@ class const RoomSummary({
required final String roomId,
@JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule,
/// Rooms whose members may join without an invite, for restricted rooms.
final IList<String> allowedRoomIds = const .empty(),
final String? name,
final Uri? avatarUrl,
final String? canonicalAlias,
@ -41,6 +45,14 @@ class const RoomSummary({
/// Whether this is an invite to a direct message.
@JsonKey(includeFromJson: false) final bool isDirect = false,
}) with _$RoomSummary {
/// Whether the user has to knock rather than join, given the rooms they've
/// joined. Restricted knock rooms can be joined directly from an allowed room.
bool mustKnock(ISet<String> joinedRooms) => switch (joinRule) {
.knock => true,
.knockRestricted => !allowedRoomIds.any(joinedRooms.contains),
_ => false,
};
/// A summary of a room the user is in, from its synced metadata.
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
roomId: metadata.id,

View file

@ -1,6 +1,7 @@
import "package:material_ui/material_ui.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room_order.dart";
import "package:nexus/models/show_and_send.dart";
part "settings.freezed.dart";
part "settings.g.dart";
@ -14,7 +15,13 @@ class const Settings({
final bool showAllInHome = false,
final bool showMinimizeButton = false,
final bool swipeToReply = false,
final bool hideMembershipEvents = false,
final RoomOrder roomOrder = .unreadFirst,
final ShowAndSend typingIndicators = .showAndSend,
/// When not sending, read receipts are still sent privately, so that the
/// user's other devices know what has been read.
final ShowAndSend readReceipts = .showAndSend,
}) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -0,0 +1,2 @@
/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts.
enum ShowAndSend { showAndSend, show, neither }

View file

@ -11,10 +11,10 @@ part "sync_data.g.dart";
@JsonSerializable(createToJson: false)
class const SyncData({
final bool clearState = false,
final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
final IMap<String, Room> rooms = const IMap.empty(),
final ISet<String> leftRooms = const ISet.empty(),
final IList<InvitedRoom> invitedRooms = const IList.empty(),
final IMap<String, IMap<String, dynamic>> accountData = const .empty(),
final IMap<String, Room> rooms = const .empty(),
final ISet<String> leftRooms = const .empty(),
final IList<InvitedRoom> invitedRooms = const .empty(),
final IMap<String, IList<SpaceEdge>>? spaceEdges,
final IList<String>? topLevelSpaces,
}) with _$SyncData {

14
lib/models/typing.dart Normal file
View file

@ -0,0 +1,14 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "typing.freezed.dart";
part "typing.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(createToJson: false)
class const Typing({
required final String roomId,
final IList<String> userIds = const .empty(),
}) with _$Typing {
factory Typing.fromJson(Map<String, Object?> json) => _$TypingFromJson(json);
}

View file

@ -23,23 +23,26 @@ final class const Appbar({
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> maximize() async {
final isMaximized = await windowManager.isMaximized();
final isDesktop = !(Platform.isAndroid || Platform.isIOS);
if (isMaximized) {
return windowManager.unmaximize();
void maximize() {
final window = WindowManager.instance.getCurrent();
if (window == null) return;
if (window.isMaximized) {
return window.unmaximize();
}
return windowManager.maximize();
return window.maximize();
}
return GestureDetector(
onPanStart: ref
.watch(SettingsController.provider)
.whenOrNull(
data: (settings) => settings.linuxMobileMode
data: (settings) => !isDesktop || settings.linuxMobileMode
? null
: (_) => windowManager.startDragging(),
: (_) => WindowManager.instance.getCurrent()?.startDragging(),
),
child: AppBar(
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
@ -50,10 +53,12 @@ final class const Appbar({
onTap: onTap,
child: IgnorePointer(child: title),
),
flexibleSpace: GestureDetector(onDoubleTap: maximize),
flexibleSpace: GestureDetector(
onDoubleTap: isDesktop ? maximize : null,
),
actions: [
...actions,
if (windowControls && !(Platform.isAndroid || Platform.isIOS)) ...[
if (windowControls && isDesktop) ...[
if (ref
.watch(SettingsController.provider)
.whenOrNull(
@ -62,7 +67,8 @@ final class const Appbar({
false)
IconButton(
tooltip: "Minimize window",
onPressed: windowManager.minimize,
onPressed: () =>
WindowManager.instance.getCurrent()?.minimize(),
icon: const Icon(Icons.minimize),
),
if (!Platform.isLinux)

View file

@ -0,0 +1,39 @@
import "package:material_ui/material_ui.dart";
class const AvatarRing({
required final String tooltip,
required final VoidCallback onTap,
required final Widget child,
final double dimension = 24,
super.key,
}) extends StatelessWidget {
static const ringWidth = 1.5;
@override
Widget build(BuildContext context) => Tooltip(
message: tooltip,
child: Container(
padding: .all(ringWidth),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: .circle,
),
child: ClipOval(
child: SizedBox.square(
dimension: dimension,
child: Stack(
fit: .expand,
children: [
child,
// Over the child, so the ripple shows on top of avatars
Material(
type: .transparency,
child: InkWell(onTap: onTap),
),
],
),
),
),
),
);
}

View file

@ -0,0 +1,41 @@
import "dart:math";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
/// Three dots that bounce one after another, in a loop.
class const BouncingDots({required final Color color, super.key})
extends HookWidget {
@override
Widget build(BuildContext context) {
final controller = useAnimationController(
duration: const .new(milliseconds: 1200),
);
useEffect(() {
controller.repeat();
return null;
}, []);
final progress = useAnimation(controller);
return Row(
mainAxisSize: .min,
spacing: 3,
children: [0, 1, 2].map((index) {
final phase = (progress - index * 0.15) % 1;
final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0;
return Transform.translate(
offset: .new(0, -3 * bounce),
child: Opacity(
opacity: 0.4 + 0.6 * bounce,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: .circle),
),
),
);
}).toList(),
);
}
}

View file

@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/image_picker.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:image_picker/image_picker.dart";
@ -94,6 +96,44 @@ class const Composer(
return null;
}, [relationType, relatedEvent == null]);
final sinceTypingSent = useMemoized(Stopwatch.new);
useEffect(() {
final client = ref.read(ClientController.provider.notifier);
void stopTyping() {
if (!sinceTypingSent.isRunning) return;
sinceTypingSent
..stop()
..reset();
client.setTyping(roomId, .zero).ignore();
}
void onChanged() {
final shouldSend =
ref.read(SettingsController.provider).value?.typingIndicators ==
.showAndSend;
if (!shouldSend || controller.text.isEmpty) return stopTyping();
if (sinceTypingSent.isRunning &&
sinceTypingSent.elapsed < const .new(seconds: 5)) {
return;
}
sinceTypingSent
..reset()
..start();
client.setTyping(roomId, const .new(seconds: 10)).ignore();
}
controller.addListener(onChanged);
return () {
controller.removeListener(onChanged);
stopTyping();
};
}, [roomId]);
final attachment = ref.watch(AttachmentController.provider(roomId));
Future<void> attach(Future<XFile?> pick) async {

View file

@ -0,0 +1,113 @@
import "package:collection/collection.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_typing.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/widgets/bouncing_dots.dart";
class const TypingIndicator(final String roomId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final typing = ref.watch(RoomTypingController.provider(roomId));
// Past three people, only the first two are named
final named = typing.length > 3 ? 2 : typing.length;
final subjects = [
...typing
.take(named)
.map(
(typingUserId) => WidgetSpan(
alignment: .baseline,
baseline: .alphabetic,
child: Builder(
builder: (context) => InkWell(
onTap: () =>
context.showUserPopover(typingUserId, roomId: roomId),
child: Text(
ref
.watch(
UserController.provider(
.new(roomId: roomId, userId: typingUserId),
),
)
.value
?.displayName ??
typingUserId.localpart,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: .bold,
),
),
),
),
),
),
if (typing.length > named)
TextSpan(text: "${typing.length - named} others"),
];
final spans = [
...subjects.expandIndexed(
(index, subject) => [
if (index > 0)
TextSpan(
text: index < subjects.length - 1
? ", "
: subjects.length > 2
? ", and "
: " and ",
),
subject,
],
),
TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."),
];
return AnimatedSwitcher(
duration: Durations.medium2,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
alignment: .bottomLeft,
child: child,
),
),
child: typing.isEmpty
? const SizedBox.shrink()
: Align(
alignment: .bottomLeft,
child: Material(
color: theme.colorScheme.surfaceContainerHigh,
elevation: 1,
shape: const StadiumBorder(),
child: Padding(
padding: .symmetric(horizontal: 12, vertical: 4),
child: Row(
mainAxisSize: .min,
spacing: 8,
children: [
BouncingDots(color: theme.colorScheme.onSurfaceVariant),
Flexible(
child: Text.rich(
TextSpan(children: spans),
maxLines: 1,
overflow: .ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
);
}
}

View file

@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key})
? SizedBox.shrink()
: InkWell(
onTap: () async {
if (data case MembershipContent membership) {
context.showUserPopover(membership, mention, roomId: roomId);
if (data is MembershipContent) {
context.showUserPopover(mention, roomId: roomId);
} else if (data case RoomSummary summary) {
final eventRowId = linkInfo?.eventId == null
? null

View file

@ -0,0 +1,51 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/join_room.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Asks the moderators of a room to let the user in, with an optional reason.
///
/// Pops with true once the request has been sent.
class const KnockSheet(
final String roomId, {
required final String name,
final IList<String> via = const .empty(),
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final reason = useTextEditingController();
void onKnock() {
context.knockRoom(
.new(
roomIdOrAlias: roomId,
via: via,
reason: reason.text.isEmpty ? null : reason.text,
),
name: name,
);
Navigator.of(context).pop(true);
}
return SheetLayout(
title: Text("Knock on $name"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(onPressed: onKnock, child: Text("Knock")),
],
children: [
Text(
"This room requires knocking to join. Its moderators will be able to see your knock and let you in.",
),
TextField(
textCapitalization: .sentences,
controller: reason,
decoration: .new(labelText: "Reason (optional)"),
onEditingComplete: onKnock,
),
],
);
}
}

View file

@ -12,18 +12,17 @@ class const MessageAvatar(
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
AuthorController.provider(event),
)) {
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTap: () =>
context.showUserPopover(value, event.sender, roomId: event.roomId),
child: AvatarOrHash(
value.avatarUrl,
value.displayName ?? event.sender.localpart,
dimension: height,
),
),
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
};
Widget build(BuildContext context, WidgetRef ref) =>
switch (ref.watch(AuthorController.provider(event))) {
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTap: () =>
context.showUserPopover(event.sender, roomId: event.roomId),
child: AvatarOrHash(
value.avatarUrl,
value.displayName ?? event.sender.localpart,
dimension: height,
),
),
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
};
}

View file

@ -21,12 +21,8 @@ class const MessageDisplayname(
AsyncData(:final MembershipContent? value) ||
AsyncLoading(:final value) ||
AsyncError(:final value) => InkWell(
onTap: clickable && value != null
? () => context.showUserPopover(
value,
event.sender,
roomId: event.roomId,
)
onTap: clickable
? () => context.showUserPopover(event.sender, roomId: event.roomId)
: null,
child: switch ([
Text(

View file

@ -25,6 +25,7 @@ class const MemberList(final String roomId, {super.key})
final options = <String, MembershipStatus>{
"Joined": .join,
"Invited": .invite,
"Knocked": .knock,
"Banned": .ban,
};
final status = options.values.toIList()[statusIndex.value];
@ -36,7 +37,7 @@ class const MemberList(final String roomId, {super.key})
body: Column(
children: [
Padding(
padding: .symmetric(vertical: 8),
padding: .all(8),
child: M3EToggleButtonGroup(
selectedIndex: statusIndex.value,
onSelectedIndexChanged: (index) =>
@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key})
itemBuilder: (context, index) =>
switch (members[index].content) {
MembershipContent(
:final avatarUrl,
:final displayName,
) &&
final content =>
:final avatarUrl,
:final displayName,
) =>
Builder(
builder: (context) => ListTile(
onTap: () =>
context.showUserPopover(
content,
members[index].stateKey!,
roomId: roomId,
),

View file

@ -26,7 +26,7 @@ final class const MessageImage(
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
child: Image(
image: MxcImage(ref, request),
width: info?.width,
width: info?.width?.toDouble(),
fit: BoxFit.fitWidth,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null
@ -40,7 +40,7 @@ final class const MessageImage(
child: BlurHash(hash: blurHash),
)
: SizedBox(
width: info!.width,
width: info!.width!.toDouble(),
child: BlurHash(hash: blurHash),
),
_ => Loading(),

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/mutual_rooms.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// The rooms the user shares with [userId].
class const MutualRoomsSheet(final String userId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
title: Text("Mutual Rooms"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
],
children: [
ref
.watch(MutualRoomsController.provider(userId))
.betterWhen(
data: (roomIds) {
final rooms = ref.watch(
RoomsController.provider.select(
(allRooms) => roomIds
.map((roomId) => allRooms[roomId]?.metadata)
.nonNulls
.toIList(),
),
);
return rooms.isEmpty
? Text("No mutual rooms")
: Column(
children: rooms
.map(
(room) => ListTile(
leading: AvatarOrHash(
room.avatar,
room.name ?? "Unnamed Room",
fallback: Icon(Icons.numbers),
),
title: Text(
room.name ?? "Unnamed Room",
overflow: .ellipsis,
),
onTap: () async {
final container = ProviderScope.containerOf(
context,
);
// Closes the user popover under this too
Navigator.of(context)
.popUntil((route) => route.isFirst);
await container.focusRoom(room.id);
container
.read(
SidebarPageController.provider.notifier,
)
.set(.rooms);
},
),
)
.toList(),
);
},
),
],
);
}

View file

@ -1,247 +1,19 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_about_sheet.dart";
import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:nexus/widgets/settings/settings_layout.dart";
class const SettingsPage({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (_, constraints) => HookBuilder(
builder: (context) {
final categoriesArePages = constraints.maxWidth < 550;
final selected = useState(0);
final highlightedMatch = useState<(int, int)?>(null);
final listController = useRef(ListController());
final scrollController = useScrollController();
final searchBar = SearchAnchor.bar(
barHintText: "Search...",
isFullScreen: categoriesArePages,
suggestionsBuilder: (suggestionsContext, controller) async {
final categories = await ref.read(
SettingsSectionsController.provider.future,
);
final query = controller.text.toLowerCase();
final matches = categories.values.flattenedToList
.asMap()
.entries
.expand(
(categoryEntry) => categoryEntry.value.settings
.asMap()
.entries
.where(
(settingEntry) =>
settingEntry.value.title.toLowerCase().contains(
query,
) ||
settingEntry.value.description
.toLowerCase()
.contains(query),
)
.map(
(settingEntry) => (
(categoryEntry.key, settingEntry.key),
settingEntry.value,
),
),
)
.toIList();
return matches.map(
(match) => ListTile(
onTap: () async {
if (context.mounted) Navigator.of(suggestionsContext).pop();
controller.text = "";
if (categoriesArePages) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsCategoryPage(
match.$1.$1,
initialHighlight: match.$1.$2,
),
),
);
} else {
selected.value = match.$1.$1;
listController.value.animateToItem(
index: match.$1.$2,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => Durations.extralong1,
curve: (_) => Curves.easeInOut,
);
highlightedMatch.value = match.$1;
await Future.delayed(Durations.extralong4, () {
if (highlightedMatch.value == match.$1) {
highlightedMatch.value = null;
}
});
}
},
leading: Icon(match.$2.icon),
title: Text(match.$2.title),
subtitle: Text(match.$2.description),
),
);
},
);
final settingsContent = Scaffold(
appBar: AppBar(
title: Text("Settings"),
actionsPadding: .symmetric(horizontal: 12),
actions: [
IconButton(
onPressed: () => context.showAboutSheet(ref),
icon: Icon(Icons.info_outline),
),
],
),
body: ref
.watch(SettingsSectionsController.provider)
.betterWhen(
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12).copyWith(bottom: 8),
child: searchBar,
),
),
...sections
.mapTo(
(section, categories) => [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 4),
child: DividerText(section),
),
),
SliverM3ECardList(
padding: .symmetric(
horizontal: 4,
vertical: 8,
),
margin: .symmetric(horizontal: 12),
color: Theme.of(context)
.colorScheme
.primaryContainer,
itemCount: categories.length,
onTap: (index) => Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) =>
SettingsCategoryPage(
sections
.values
.flattenedToList
.indexOf(
categories[index],
),
),
),
),
itemBuilder: (context, index) => ListTile(
leading: Icon(categories[index].icon),
title: Text(categories[index].title),
),
),
],
)
.flattened,
],
)
: Row(
children: [
MaterialUiCompatibilityBridge(
child: NavigationRailM3E(
type: .alwaysExpand,
trailing: searchBar,
scrollable: true,
sections: sections
.mapTo(
(
categoryGroup,
categories,
) => NavigationRailM3ESection(
header: DividerText(categoryGroup),
destinations: categories
.map(
(category) =>
NavigationRailM3EDestination(
icon: Icon(category.icon),
label: category.title,
),
)
.toList(),
),
)
.toList(),
selectedIndex: selected.value,
onDestinationSelected: (value) =>
selected.value = value,
),
),
VerticalDivider(),
Expanded(
child: SuperListView(
listController: listController.value,
controller: scrollController,
padding: .symmetric(vertical: 12),
children: sections
.values
.flattenedToList[selected.value]
.settings
.mapIndexed(
(index, setting) => Padding(
padding: .only(bottom: 4),
child: HighlightWrapper(
setting.builder(
setting.title,
setting.description,
setting.icon,
),
isHighlighted:
highlightedMatch.value ==
(selected.value, index),
),
),
)
.toList(),
),
),
],
),
),
);
return constraints.maxWidth < 650
? settingsContent
: Dialog(
constraints: .loose(Size(900, 600)),
child: ClipRRect(
borderRadius: BorderRadiusGeometry.circular(12),
child: settingsContent,
),
);
},
),
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout(
title: "Settings",
sectionsProvider: SettingsSectionsController.provider,
actions: [
IconButton(
onPressed: () => context.showAboutSheet(ref),
icon: Icon(Icons.info_outline),
),
],
);
}

View file

@ -4,13 +4,19 @@ import "package:collection/collection.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:hooks_riverpod/misc.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const SettingsCategoryPage(
final int index, {
required final ProviderListenable<
AsyncValue<IMap<String, IList<SettingsCategory>>>
>
sectionsProvider,
final int? initialHighlight,
super.key,
}) extends HookConsumerWidget {
@ -45,8 +51,9 @@ class const SettingsCategoryPage(
}, []);
return ref
.watch(SettingsSectionsController.provider)
.watch(sectionsProvider)
.betterWhen(
skipLoadingOnReload: true,
data: (sections) => Scaffold(
appBar: AppBar(
title: Text(sections.values.flattenedToList[index].title),

View file

@ -28,6 +28,7 @@ class const AudioPlayer(
final audio = await ref
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
if (!context.mounted) return;
await player.open(Media(audio.path), play: false);
if (context.mounted) loaded.value = true;
});

View file

@ -30,6 +30,7 @@ class const VideoPlayer(
final video = await ref
.read(ClientController.provider.notifier)
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
if (!context.mounted) return;
await player.open(Media(video.path), play: false);
if (context.mounted) loaded.value = true;
});

View file

@ -0,0 +1,112 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/read_receipts.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/avatar_ring.dart";
import "package:nexus/widgets/read_receipts_sheet.dart";
class const ReadReceiptsRow(
final String eventId, {
required final ChatParams chat,
super.key,
}) extends ConsumerWidget {
static const avatarSize = 24.0;
static const overlap = 6.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final receipts = ref.watch(
ReadReceiptsController.provider(chat)
.select((receipts) => receipts[eventId] ?? const .empty()),
);
return AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOutCubicEmphasized,
alignment: .topRight,
child: receipts.isEmpty
? const SizedBox(width: .infinity)
: Padding(
// Clear of the scrollbar
padding: .only(right: 16, bottom: 2),
child: LayoutBuilder(
builder: (context, constraints) {
const width = avatarSize + AvatarRing.ringWidth * 2;
final fits =
(constraints.maxWidth - width) ~/ (width - overlap) + 1;
// If they don't all fit, the last spot shows how many more there are
final shown = receipts.length <= fits
? receipts.length
: fits - 1;
return Align(
alignment: .centerRight,
child: Wrap(
spacing: -overlap,
children: [
...receipts
.take(shown)
.map(
(receipt) => Consumer(
builder: (context, ref, _) {
final member = ref
.watch(
UserController.provider(
.new(
roomId: chat.roomId,
userId: receipt.userId,
),
),
)
.value;
final name =
member?.displayName ??
receipt.userId.localpart;
return AvatarRing(
tooltip: name,
onTap: () => context.showUserPopover(
receipt.userId,
roomId: chat.roomId,
),
child: AvatarOrHash(
member?.avatarUrl,
name,
dimension: avatarSize,
),
);
},
),
),
if (receipts.length > shown)
AvatarRing(
tooltip: "Show everyone who has read up to here",
onTap: () => context.showAdaptiveSheet(
(_) => ReadReceiptsSheet(eventId, chat: chat),
),
child: ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Text(
"+${receipts.length - shown}",
style: theme.textTheme.labelSmall,
),
),
),
),
],
),
);
},
),
),
);
}
}

View file

@ -0,0 +1,54 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/read_receipts.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Everyone who has read up to an event, kept up to date while open.
class const ReadReceiptsSheet(
final String eventId, {
required final ChatParams chat,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
title: Text("Read by"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
],
children: ref
.watch(
ReadReceiptsController.provider(chat)
.select((receipts) => receipts[eventId] ?? const .empty()),
)
.map(
(receipt) => Consumer(
builder: (context, ref, _) {
final member = ref
.watch(
UserController.provider(
.new(roomId: chat.roomId, userId: receipt.userId),
),
)
.value;
final name = member?.displayName ?? receipt.userId.localpart;
return ListTile(
leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40),
title: Text(name, overflow: .ellipsis),
subtitle: Text(receipt.userId, overflow: .ellipsis),
onTap: () => context.showUserPopover(
receipt.userId,
roomId: chat.roomId,
),
);
},
),
)
.toList(),
);
}

View file

@ -6,27 +6,27 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/pinned_events.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/read_receipts_row.dart";
import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/swipe_to_reply.dart";
@ -43,6 +43,10 @@ class const EventRenderer(
/// If set, events can be swiped, calling this
final VoidCallback? onSwipe,
/// If set, shows who in this chat has read up to this event, e.g. in the
/// timeline.
final ChatParams? readReceiptsChat,
super.key,
}) extends HookConsumerWidget {
@override
@ -78,10 +82,24 @@ class const EventRenderer(
if (event == null) return SizedBox.shrink();
final hideMembershipEvents = (ref
.watch(SettingsController.provider)
.maybeWhen(
orElse: () => false,
data: (settings) => settings.hideMembershipEvents,
));
// Knocks and their outcomes stay visible, as moderators need to act on them
if (hideMembershipEvents &&
event.content is MembershipContent &&
![event.content, event.previousContent].any(
(content) => content is MembershipContent && content.status == .knock,
)) {
return SizedBox.shrink();
}
final isGrouped =
previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
(previousEvent?.isShownAsMessage ?? false) &&
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id;
@ -94,7 +112,26 @@ class const EventRenderer(
TextSpan(text: text),
]);
final child = event.redactedBy != null || event.relationType == "m.replace"
final child = event.isShownAsMessage
? MessageRenderer(
event,
onTapReply: jumpToEvent == null
? null
: () async {
final replyEvent = await ref.read(
EventController.provider(
.new(roomId: roomId, eventId: event.replyTo!),
).future,
);
if (replyEvent != null) {
await jumpToEvent!(replyEvent.rowId);
}
},
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
)
: event.redactedBy != null || event.relationType == "m.replace"
? null
: switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row(
@ -106,27 +143,6 @@ class const EventRenderer(
],
),
MessageContent() ||
EncryptedContent() ||
StickerContent() => MessageRenderer(
event,
onTapReply: jumpToEvent == null
? null
: () async {
final replyEvent = await ref.read(
EventController.provider(
.new(roomId: roomId, eventId: event.replyTo!),
).future,
);
if (replyEvent != null) {
await jumpToEvent!(replyEvent.rowId);
}
},
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
),
MembershipContent content => switch (event.previousContent) {
MembershipContent(:final status) =>
status == content.status
@ -268,6 +284,10 @@ class const EventRenderer(
...[
if (child is! MessageRenderer) ReactionRow(event),
// Outside the message itself, so it doesn't move when swiped
if (readReceiptsChat case final chat? when event.isShownAsMessage)
ReadReceiptsRow(event.eventId, chat: chat),
if (event.sendError != null && event.sendError != "not sent")
Padding(
padding: .only(bottom: 4),

View file

@ -19,6 +19,11 @@ class const MembershipRenderer(
"Make sure to only pass membership events to MembershipRenderer",
);
final previous = switch (event.previousContent) {
MembershipContent(:final status) => status,
_ => null,
};
return switch (event.content) {
MembershipContent content => GenericEventRenderer(
Icons.people,
@ -28,7 +33,6 @@ class const MembershipRenderer(
alignment: .middle,
child: InkWell(
onTap: () => context.showUserPopover(
content,
event.stateKey!,
roomId: event.roomId,
),
@ -43,9 +47,10 @@ class const MembershipRenderer(
TextSpan(
text:
"${switch (content.status) {
.invite => "was invited to",
.invite => previous == .knock ? "was accepted into" : "was invited to",
.join => "joined",
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
.leave when previous == .knock => event.sender == event.stateKey ? "retracted their knock on" : "had their knock denied in",
.leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"),
.ban => "was banned from",
.knock => "asked to join",
}} the room${event.sender == event.stateKey ? "" : " by"}",

View file

@ -16,6 +16,7 @@ final class const RoomAppbar({
required final void Function() onOpenDrawer,
final void Function(BuildContext context)? onOpenMemberList,
final void Function()? onOpenPinnedMessagesList,
final void Function()? onOpenSearch,
super.key,
}) extends ConsumerWidget implements PreferredSizeWidget {
@override
@ -61,6 +62,11 @@ final class const RoomAppbar({
room == null
? []
: [
IconButton(
onPressed: onOpenSearch?.call,
icon: Icon(Icons.search),
tooltip: "Search this room",
),
IconButton(
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart";
@ -7,12 +8,15 @@ import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({
required final String roomId,
required final ChatParams chat,
required final ChatScroll scroll,
required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent,
required final double composerHeight,
/// The typing indicator's height, or zero while it's hidden.
required final double typingIndicatorHeight,
final void Function(int rowId)? onSwipe,
super.key,
}) extends StatelessWidget {
@ -24,10 +28,11 @@ class const ChatTimeline({
EventRenderer(
eventRowId,
previousEventRowId: previousEventRowId,
roomId: roomId,
roomId: chat.roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
readReceiptsChat: chat,
),
key: key,
isHighlighted: highlightedEvent == eventRowId,
@ -40,7 +45,22 @@ class const ChatTimeline({
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
SliverToBoxAdapter(
child: Column(
children: [
// Makes room for the typing indicator, with a small gap above it,
// so it doesn't cover messages
AnimatedContainer(
duration: Durations.medium2,
curve: Curves.easeInOutCubicEmphasized,
height: typingIndicatorHeight == 0
? 0
: typingIndicatorHeight + 4,
),
SizedBox(height: composerHeight),
],
),
),
SuperSliverList.builder(
itemCount: scroll.liveRows.length,

View file

@ -15,11 +15,13 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/composer/typing_indicator.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/search_drawer.dart";
import "package:nexus/widgets/sync_status_banner.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
@ -45,6 +47,7 @@ final class const RoomChat({
final highlightedEvent = useState<int?>(null);
final composerSize = useState<double>(64);
final typingIndicatorSize = useState<double>(0);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
@ -82,10 +85,11 @@ final class const RoomChat({
),
);
final controllerProvider = RoomChatController.provider((
final ChatParams chat = (
roomId: roomId,
contextualEvent: contextualEvent.value,
));
);
final controllerProvider = RoomChatController.provider(chat);
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.read(ClientController.provider.notifier);
@ -198,115 +202,148 @@ final class const RoomChat({
);
return Scaffold(
endDrawer: PinnedEventsDrawer(
endDrawerEnableOpenDragGesture: false,
endDrawer: SearchDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
builder: (searchContext) => Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relatedEventRowId: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
),
),
],
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext)
.openEndDrawer,
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
),
],
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
chat: chat,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
typingIndicatorHeight:
typingIndicatorSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(
:final error,
:final stackTrace,
) =>
ErrorDialog(error, stackTrace),
},
),
),
AnimatedPositioned(
duration: Durations.short4,
bottom: composerSize.value,
left: 12,
// Leaves room for the jump to bottom button when it's shown
right: scroll.atBottom ? 12 : 72,
child: MeasureSize(
onChange: (size) =>
typingIndicatorSize.value = size.height,
child: TypingIndicator(roomId),
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(
Icons.keyboard_arrow_down,
),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relatedEventRowId:
relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
),
),
],
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
),
],
),
),
),
),
),

View file

@ -7,7 +7,9 @@ import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_settings_sections.dart";
import "package:nexus/widgets/adaptive_menu_button.dart";
import "package:nexus/widgets/settings/settings_layout.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/room.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@ -15,7 +17,7 @@ import "package:nexus/main.dart";
final class const RoomMenuButton(
final Room? room, {
final IList<Room> children = const IList.empty(),
final IList<Room> children = const .empty(),
super.key,
}) extends ConsumerWidget {
@override
@ -122,6 +124,21 @@ final class const RoomMenuButton(
title: Text("Invite"),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => SettingsLayout(
title: "Room Settings",
sectionsProvider: RoomSettingsSectionsController.provider(
room!.metadata!.id,
),
),
),
child: ListTile(
leading: Icon(Icons.settings),
title: Text("Settings"),
),
),
PopupMenuItem(
onTap: () => context.showLeaveRoomSheet(
room!.metadata!.id,

View file

@ -2,12 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/invites.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/join_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/knock_sheet.dart";
import "package:nexus/widgets/linkified_text.dart";
import "package:nexus/models/room_summary.dart";
@ -15,11 +19,17 @@ class const RoomSummarySheet(
final RoomSummary summary, {
final IList<String>? via,
super.key,
}) extends StatelessWidget {
}) extends ConsumerWidget {
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
final isInvite = summary.membership == .invite;
final isKnocking = summary.membership == .knock;
final mustKnock = summary.mustKnock(
ref.watch(
RoomsController.provider.select((rooms) => rooms.keys.toISet()),
),
);
final colorScheme = Theme.of(context).colorScheme;
return SheetLayout(
@ -83,7 +93,35 @@ class const RoomSummarySheet(
},
child: Text("Accept"),
),
] else if (via != null)
] else if (isKnocking)
TextButton(
onPressed: () {
final container = ProviderScope.containerOf(context);
Navigator.of(context).pop();
container
.read(ClientController.provider.notifier)
.leaveRoom(summary.roomId)
.onError(showError);
},
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
child: Text("Retract knock"),
)
else if (via != null && mustKnock)
TextButton(
onPressed: () async {
if (await context.showAdaptiveSheet<bool>(
(_) =>
KnockSheet(summary.roomId, name: name, via: via!),
) ==
true &&
context.mounted) {
Navigator.of(context).pop();
}
},
child: Text("Knock"),
)
else if (via != null)
TextButton(
onPressed: () {
context.joinRoom(
@ -139,6 +177,17 @@ class const RoomSummarySheet(
subtitle: LinkifiedText(summary.topic!),
leading: Icon(Icons.info),
),
if (isKnocking)
ListTile(
title: Text("You've knocked"),
subtitle: Text("Waiting for a moderator to let you in"),
leading: Icon(Icons.hourglass_top),
)
else if (mustKnock)
ListTile(
title: Text("Knock to join"),
leading: Icon(Icons.door_front_door),
),
if (summary.encryption != null)
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
if (summary.worldReadable == true)

View file

@ -0,0 +1,147 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/event_search.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/main.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
final class const SearchDrawer(
final String roomId, {
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final JumpToEvent jumpToEvent,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final query = useState("");
final provider = EventSearchController.provider((
roomId: roomId,
query: query.value,
));
final results = ref.watch(provider);
final theme = Theme.of(context);
void search() {
final text = controller.text.trim();
// The provider is keyed on the query, so searching again needs a refetch
if (text == query.value) {
ref.invalidate(provider, asReload: true);
} else {
query.value = text;
}
}
Widget placeholder(IconData icon, String text) => Center(
child: Column(
mainAxisSize: .min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.onSurface),
SizedBox(height: 12),
Text(text, style: theme.textTheme.headlineSmall),
],
),
);
return Drawer(
width: 400,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.search),
title: Text("Search"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close search",
),
],
),
body: Column(
children: [
Padding(
padding: .symmetric(horizontal: 12, vertical: 4),
child: TextField(
controller: controller,
autofocus: true,
textInputAction: .search,
onSubmitted: (_) => search(),
decoration: .new(
hintText: "Search this room",
prefixIcon: Icon(Icons.search),
suffixIcon: IconButton(
onPressed: search,
icon: Icon(Icons.arrow_forward),
tooltip: "Search",
),
),
),
),
Expanded(
child: query.value.isEmpty
? placeholder(Icons.search, "Search for events")
: switch (results) {
AsyncData(:final value) when value.rowIds.isEmpty =>
placeholder(Icons.search_off, "No results"),
AsyncData(:final value) ||
AsyncLoading(:final value?) => ListView.builder(
padding: .all(8),
itemCount:
value.rowIds.length +
(value.nextBatch == null ? 0 : 1),
itemBuilder: (context, index) {
if (index == value.rowIds.length) {
return Center(
child: TextButton(
onPressed: results.isLoading
? null
: () => ref
.read(provider.notifier)
.paginate()
.onError(showError),
child: Text("Load more"),
),
);
}
final rowId = value.rowIds[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
jumpToEvent(rowId);
},
child: Padding(
padding: .symmetric(vertical: 4),
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
maxLines: 2,
getEventOptions: getEventOptions,
),
),
),
);
},
),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
],
),
),
);
}
}

View file

@ -0,0 +1,112 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/members.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/settings/power_level_field.dart";
import "package:nexus/widgets/settings/search_chooser.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Chooses users of [roomId] to give a power level of their own.
class const AddUsersPowerLevelSheet({
required final String roomId,
/// Users who already have a level, or can't be given one, so aren't suggested.
required final ISet<String> existing,
/// The user's own power level, which they can't set levels above.
required final double ownLevel,
required final void Function(ISet<String> users, int level) onSave,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(const ISet<String>.empty());
final error = useState<String?>(null);
final searchController = useTextEditingController();
final levelController = useTextEditingController(text: "0");
final level = PowerLevelField.levelFrom(
useValueListenable(levelController).text,
ownLevel,
);
final members = IMap.fromEntries(
(ref.watch(MembersController.provider(roomId)).value ??
const ISet.empty())
.map(
(event) => switch ((event.stateKey, event.content)) {
(final userId?, final MembershipContent content)
when content.status == .join || content.status == .invite =>
MapEntry(userId, content),
_ => null,
},
)
.nonNulls,
);
String nameOf(String id) => members[id]?.displayName ?? id.localpart;
void add(String id) {
if (existing.contains(id)) {
error.value = "Already has a power level";
return;
}
chosen.value = chosen.value.add(id);
searchController.clear();
}
final valid = chosen.value.isNotEmpty && level != null;
void save() {
Navigator.of(context).pop();
onSave(chosen.value, level!);
}
return SheetLayout(
title: Text("Add Users"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(onPressed: valid ? save : null, child: Text("Save")),
],
children: [
SearchChooser(
controller: searchController,
chosen: chosen.value,
options: members.keys
.where((id) => !existing.contains(id))
.sortedBy((id) => nameOf(id).toLowerCase())
.toIList(),
nameOf: nameOf,
avatarOf: (id) =>
AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40),
subtitleOf: (id) => id,
onAdded: add,
onRemoved: (id) => chosen.value = chosen.value.remove(id),
onSubmitted: (text, topMatch) {
if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) {
add(text);
} else if (topMatch != null) {
add(topMatch);
} else {
error.value = "Enter a user ID";
}
},
onChanged: (_) => error.value = null,
label: "Add user",
hint: "Search members, or enter a user ID",
removeTooltip: "Remove user",
error: error.value,
autofocus: true,
),
PowerLevelField(
controller: levelController,
ownLevel: ownLevel,
onSubmitted: valid ? save : null,
),
],
);
}
}

View file

@ -0,0 +1,78 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Chooses which of a room's [aliases] it publishes, and which one of those is canonical.
class const AliasesSheet({
required final ISet<String> aliases,
required final String? canonical,
required final ISet<String> alternatives,
/// The canonical alias, and the other published ones.
required final void Function(String? canonical, ISet<String> alternatives)
onSave,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final chosenCanonical = useState(canonical);
final published = useState(alternatives.addAll([?canonical]));
void setPublished(String alias, bool isPublished) {
published.value = isPublished
? published.value.add(alias)
: published.value.remove(alias);
if (!isPublished && chosenCanonical.value == alias) {
chosenCanonical.value = null;
}
}
return SheetLayout(
title: Text("Aliases"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onSave(
chosenCanonical.value,
published.value.removeAll([?chosenCanonical.value]),
);
},
child: Text("Save"),
),
],
children: [
Text(
"Star an item to set it as the room's canonical alias, the alias used to advertise and access the room",
),
Text(
"Other checked items will be set as alternate aliases, which can still be used to join/access the room but are not as prominently advertised.",
),
if (aliases.isEmpty)
Text("No aliases, add a local alias first")
else
...aliases.map(
(alias) => CheckboxListTile(
value: published.value.contains(alias),
onChanged: (checked) => setPublished(alias, checked ?? false),
title: Text(alias, overflow: .ellipsis),
secondary: IconButton(
tooltip: chosenCanonical.value == alias
? "Canonical alias"
: "Make canonical alias",
isSelected: chosenCanonical.value == alias,
icon: Icon(Icons.star_border),
selectedIcon: Icon(Icons.star),
onPressed: () {
chosenCanonical.value = alias;
setPublished(alias, true);
},
),
),
),
],
);
}
}

View file

@ -0,0 +1,138 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/settings/search_chooser.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Chooses the rooms whose members can join [roomId] without an invite.
class const AllowedRoomsSheet({
required final String roomId,
required final ISet<String> allowed,
/// Suggested first, as they're the usual choice.
required final ISet<String> parentSpaces,
required final void Function(ISet<String> allowed) onSave,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(allowed);
final error = useState<String?>(null);
final resolving = useState(false);
final controller = useTextEditingController();
final rooms = ref.watch(
RoomsController.provider.select(
(rooms) => IMap.fromEntries(
rooms.values
.map((room) => room.metadata)
.nonNulls
.map(
(metadata) => MapEntry(metadata.id, (
name: metadata.name,
avatar: metadata.avatar,
)),
),
),
),
);
String nameOf(String id) => rooms[id]?.name ?? id;
final suggestions = parentSpaces
.addAll(
rooms.entries
.where((entry) => !parentSpaces.contains(entry.key))
.sortedBy((entry) => entry.value.name?.toLowerCase() ?? "")
.map((entry) => entry.key),
)
.where((id) => id != roomId)
.toIList();
void add(String id) {
if (id == roomId) {
error.value = "A room can't allow itself";
return;
}
if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id);
controller.clear();
}
Future<void> addRoom(String text) async {
if (text.startsWith("!")) return add(text);
resolving.value = true;
try {
final id = await ref
.read(ClientController.provider.notifier)
.resolveAlias(text);
if (!context.mounted) return;
if (id == null) {
error.value = "No room has this alias";
} else {
add(id);
}
} finally {
if (context.mounted) resolving.value = false;
}
}
return SheetLayout(
title: Text("Allowed Rooms"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onSave(chosen.value);
},
child: Text("Save"),
),
],
children: [
Text("Members of these rooms can join without an invite."),
SearchChooser(
controller: controller,
chosen: chosen.value,
options: suggestions,
nameOf: nameOf,
avatarOf: (id) => AvatarOrHash(
rooms[id]?.avatar,
nameOf(id),
fallback: Icon(Icons.numbers),
),
subtitleOf: (id) => parentSpaces.contains(id)
? "Parent space"
: rooms[id]?.name == null
? null
: id,
onAdded: add,
onRemoved: (id) => chosen.value = chosen.value.remove(id),
onSubmitted: (text, topMatch) {
if (text.startsWith(RegExp("[!#]"))) {
addRoom(text).onError(showError);
} else if (topMatch != null) {
add(topMatch);
} else {
error.value = "Enter a room ID or alias";
}
},
onChanged: (_) => error.value = null,
label: "Add room",
hint: "Search, or enter a room ID or alias",
removeTooltip: "Remove room",
emptyText: "None, so only invited users can join",
error: error.value,
loading: resolving.value,
),
],
);
}
}

View file

@ -1,6 +1,9 @@
import "dart:async";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/settings/radio_sheet.dart";
final class const DialogListTile<T>({
@ -8,32 +11,44 @@ final class const DialogListTile<T>({
required final Icon icon,
required final String title,
required final List<T> options,
required final void Function(T value)? onChanged,
/// While a returned future runs, the tile is disabled.
required final FutureOr<void> Function(T value)? onChanged,
required final String Function(T option) getName,
final Widget? subtitle,
super.key,
}) extends ConsumerWidget {
}) extends HookWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => ListTile(
enabled: onChanged != null,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged,
value: value,
options: options,
Widget build(BuildContext context) {
final saving = Saving.use();
Future<void> change(T value) => saving.run(() => onChanged!(value));
return ListTile(
enabled: onChanged != null && !saving.active,
onTap: () => context.showAdaptiveSheet(
(context) => RadioSheet<T>(
title: title,
getName: getName,
onChanged: onChanged == null ? null : change,
value: value,
options: options,
),
),
),
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: Chip(
label: Text(
value == null ? "None" : getName(value as T),
overflow: TextOverflow.ellipsis,
style: onChanged == null ? .new(color: Colors.grey) : null,
title: Text(title),
subtitle: subtitle,
leading: icon,
trailing: ConstrainedBox(
constraints: const .new(maxWidth: 160),
child: Chip(
label: Text(
value is T ? getName(value as T) : "None",
overflow: TextOverflow.ellipsis,
style: onChanged == null || saving.active
? .new(color: Colors.grey)
: null,
),
),
),
),
);
);
}
}

View file

@ -0,0 +1,107 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/gomuks_exception.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const LocalAliasesSheet(
final String roomId, {
/// Called after an alias is deleted, e.g. to stop publishing it.
final Future<void> Function(String alias)? onRemoved,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = LocalAliasesController.provider(roomId);
final saving = Saving.use();
final controller = useTextEditingController();
final localpart = useValueListenable(controller).text.trim();
final server = ref.watch(
ClientStateController.provider.select(
(state) => state?.userId?.split(":").skip(1).join(":"),
),
);
final error = useState<String?>(null);
void add() {
if (localpart.isEmpty || server == null) return;
saving
.run(() async {
try {
await ref.read(provider.notifier).add("#$localpart:$server");
controller.clear();
} on GomuksException catch (exception) {
// Taken, by this room or another, which is shown on the field
if (exception.statusCode != 409) rethrow;
if (context.mounted) error.value = "This alias already exists";
}
})
.onError(showError);
}
return SheetLayout(
title: Text("Local Aliases"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
],
children: [
Text(
"Addresses for the room on your server. Publish them to show them to others.",
),
ref
.watch(provider)
.betterWhen(
data: (aliases) => aliases.isEmpty
? Text("No local aliases")
: Column(
children: aliases
.map(
(alias) => ListTile(
title: Text(alias, overflow: .ellipsis),
trailing: IconButton(
tooltip: "Delete alias",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => saving
.run(() async {
await ref
.read(provider.notifier)
.remove(alias);
await onRemoved?.call(alias);
})
.onError(showError),
),
),
)
.toList(),
),
),
TextField(
controller: controller,
enabled: !saving.active,
onChanged: (_) => error.value = null,
onSubmitted: (_) => add(),
decoration: .new(
labelText: "New alias",
errorText: error.value,
prefixText: "#",
suffixText: server == null ? null : ":$server",
suffixIcon: IconButton(
tooltip: "Add alias",
icon: Icon(Icons.add),
onPressed: localpart.isEmpty || saving.active ? null : add,
),
),
),
],
);
}
}

View file

@ -0,0 +1,55 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/settings/power_level_field.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Gives something, like an event type, its own power level.
class const PowerLevelEntrySheet({
required final String title,
/// The label of the field for what's given the power level.
required final String keyLabel,
required final String keyHint,
/// The user's own power level, which they can't set levels above.
required final double ownLevel,
required final void Function(String key, int level) onSave,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final keyController = useTextEditingController();
final levelController = useTextEditingController(text: "0");
final key = useValueListenable(keyController).text.trim();
final level = PowerLevelField.levelFrom(
useValueListenable(levelController).text,
ownLevel,
);
final valid = key.isNotEmpty && level != null;
void save() {
Navigator.of(context).pop();
onSave(key, level!);
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(onPressed: valid ? save : null, child: Text("Save")),
],
children: [
TextField(
controller: keyController,
autofocus: true,
decoration: .new(labelText: keyLabel, hintText: keyHint),
),
PowerLevelField(
controller: levelController,
ownLevel: ownLevel,
onSubmitted: valid ? save : null,
),
],
);
}
}

View file

@ -0,0 +1,48 @@
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
/// A field for a power level, which can't be above the user's own.
class const PowerLevelField({
required final TextEditingController controller,
/// The user's own power level, which they can't set levels above.
required final double ownLevel,
final VoidCallback? onSubmitted,
final bool autofocus = false,
super.key,
}) extends HookWidget {
/// The furthest from zero Matrix allows integers to be.
static const maxMagnitude = (2 ^ 53) - 1;
/// The level in [text], if it's one someone with [ownLevel] can set.
static int? levelFrom(String text, double ownLevel) => switch (int.tryParse(
text,
)) {
final level? when level.abs() <= maxMagnitude && level <= ownLevel => level,
_ => null,
};
@override
Widget build(BuildContext context) {
final text = useValueListenable(controller).text;
return TextField(
controller: controller,
autofocus: autofocus,
keyboardType: .numberWithOptions(signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))],
onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(),
decoration: .new(
labelText: "Power level",
errorText: switch (int.tryParse(text)) {
_ when text.isEmpty || text == "-" => null,
final level? when level.abs() <= maxMagnitude =>
level > ownLevel ? "Can't be above your own power level" : null,
// Including ones too far from zero to even parse
_ => "Must be from -$maxMagnitude to $maxMagnitude",
},
),
);
}
}

View file

@ -0,0 +1,120 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/settings/power_level_field.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Sets the power level needed for something.
class const PowerLevelTile({
required final Widget leading,
required final String title,
required final String? description,
required final int value,
/// The user's own power level, which they can't set or change levels above.
required final double ownLevel,
/// Disables the tile if null.
required final void Function(int value)? onChanged,
/// Shows a button to remove the level if given, disabled along with the tile.
final VoidCallback? onRemoved,
/// If given, asked before removing the level.
final String? removeConfirmation,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final enabled = onChanged != null && value <= ownLevel;
Future<void> remove() async {
if (removeConfirmation case final confirmation?) {
final confirmed = await context.showAdaptiveSheet<bool>(
(context) => SheetLayout(
title: Text("Remove Power Level"),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text("Remove"),
),
],
children: [Text(confirmation)],
),
);
if (confirmed != true) return;
}
onRemoved!();
}
return ListTile(
enabled: enabled,
leading: leading,
title: Text(title),
subtitle: description == null ? null : Text(description!),
trailing: Row(
mainAxisSize: .min,
spacing: 8,
children: [
Chip(
label: Text(
"$value",
style: enabled ? null : .new(color: Colors.grey),
),
),
if (onRemoved != null)
IconButton(
tooltip: "Remove",
icon: Icon(Icons.delete),
onPressed: enabled ? remove : null,
),
],
),
onTap: () => context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final controller = useTextEditingController(text: "$value");
final level = PowerLevelField.levelFrom(
useValueListenable(controller).text,
ownLevel,
);
void save() {
Navigator.of(context).pop();
if (level != value) onChanged!(level!);
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: level == null ? null : save,
child: Text("Save"),
),
],
children: [
PowerLevelField(
controller: controller,
ownLevel: ownLevel,
autofocus: true,
onSubmitted: level == null ? null : save,
),
],
);
},
),
),
);
}
}

View file

@ -12,7 +12,8 @@ final class const RadioSheet<T>({
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final mutValue = useState<T?>(null);
// Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet
final chosen = useState<(T,)?>(null);
return SheetLayout(
title: Text(title),
actions: [
@ -20,7 +21,7 @@ final class const RadioSheet<T>({
if (onChanged != null)
TextButton(
onPressed: () {
if (mutValue.value != null) onChanged!(mutValue.value as T);
if (chosen.value case (final picked,)) onChanged!(picked);
Navigator.of(context).pop();
},
child: Text("OK"),
@ -28,8 +29,11 @@ final class const RadioSheet<T>({
],
children: [
RadioGroup<T>(
groupValue: mutValue.value ?? value,
onChanged: (value) => mutValue.value = value ?? mutValue.value,
groupValue: switch (chosen.value) {
(final picked,) => picked,
null => value,
},
onChanged: (value) => chosen.value = (value as T,),
child: Column(
mainAxisSize: MainAxisSize.min,

View file

@ -0,0 +1,142 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
/// The [chosen] items, above a field searching [options] to choose more.
class const SearchChooser({
required final TextEditingController controller,
required final ISet<String> chosen,
required final IList<String> options,
required final String Function(String id) nameOf,
required final Widget Function(String id) avatarOf,
required final String? Function(String id) subtitleOf,
required final void Function(String id) onAdded,
required final void Function(String id) onRemoved,
/// The submitted text, and the top suggestion matching it if there is one.
required final void Function(String text, String? topMatch) onSubmitted,
required final String label,
required final String hint,
required final String removeTooltip,
final String? emptyText,
final String? error,
final bool loading = false,
final bool autofocus = false,
final void Function(String text)? onChanged,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final query = useValueListenable(controller).text.trim().toLowerCase();
final shown = options
.where(
(id) =>
!chosen.contains(id) &&
(nameOf(id).toLowerCase().contains(query) ||
id.toLowerCase().contains(query)),
)
.toIList();
ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) =>
ListTile(
leading: avatarOf(id),
title: Text(nameOf(id), overflow: .ellipsis),
subtitle: switch (subtitleOf(id)) {
final subtitle? => Text(subtitle, overflow: .ellipsis),
null => null,
},
trailing: trailing,
onTap: onTap,
);
AnimatedSize animated(Widget? child) => AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: child ?? const SizedBox(width: .infinity),
);
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
animated(switch ((chosen.isEmpty, emptyText)) {
(true, null) => null,
(true, final emptyText?) => Padding(
padding: .only(bottom: 12),
child: Text(emptyText),
),
(false, _) => Padding(
padding: .only(bottom: 12),
child: Column(
children: chosen
.map(
(id) => tileOf(
id,
trailing: IconButton(
tooltip: removeTooltip,
icon: Icon(Icons.delete),
onPressed: () => onRemoved(id),
),
),
)
.toList(),
),
),
}),
TextField(
controller: controller,
enabled: !loading,
autofocus: autofocus,
onChanged: onChanged,
onSubmitted: (text) {
final trimmed = text.trim();
if (trimmed.isNotEmpty) onSubmitted(trimmed, shown.firstOrNull);
},
decoration: .new(
labelText: label,
hintText: hint,
errorText: error,
suffixIcon: loading
? Padding(
padding: .all(12),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
),
)
: null,
),
),
animated(
shown.isEmpty
? null
: Padding(
padding: .only(top: 12, bottom: 4),
child: Material(
elevation: 4,
borderRadius: .circular(12),
clipBehavior: .antiAlias,
child: ConstrainedBox(
constraints: const .new(maxHeight: 240),
child: ListView.builder(
padding: .zero,
shrinkWrap: true,
itemExtent: 72,
itemCount: shown.length,
itemBuilder: (context, index) => tileOf(
shown[index],
onTap: () => onAdded(shown[index]),
),
),
),
),
),
),
],
);
}
}

View file

@ -0,0 +1,256 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:hooks_riverpod/misc.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
/// A settings screen split into categories: a list of them that opens each
/// as its own page on narrow screens, or a rail beside the selected one.
class const SettingsLayout({
required final String title,
required final ProviderListenable<
AsyncValue<IMap<String, IList<SettingsCategory>>>
>
sectionsProvider,
final List<Widget> actions = const [],
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (_, constraints) => HookBuilder(
builder: (context) {
final categoriesArePages = constraints.maxWidth < 550;
final selected = useState(0);
final highlightedMatch = useState<(int, int)?>(null);
final listController = useRef(ListController());
final scrollController = useScrollController();
final searchBar = SearchAnchor.bar(
barHintText: "Search...",
isFullScreen: categoriesArePages,
suggestionsBuilder: (suggestionsContext, controller) async {
// Only searched once shown, by which point these have loaded
final categories =
ref.read(sectionsProvider).value ?? const IMap.empty();
final query = controller.text.toLowerCase();
final matches = categories.values.flattenedToList
.asMap()
.entries
.expand(
(categoryEntry) => categoryEntry.value.settings
.asMap()
.entries
.where(
(settingEntry) =>
settingEntry.value.title.toLowerCase().contains(
query,
) ||
settingEntry.value.description
.toLowerCase()
.contains(query),
)
.map(
(settingEntry) => (
(categoryEntry.key, settingEntry.key),
settingEntry.value,
),
),
)
.toIList();
return matches.map(
(match) => ListTile(
onTap: () async {
if (context.mounted) Navigator.of(suggestionsContext).pop();
controller.text = "";
if (categoriesArePages) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsCategoryPage(
match.$1.$1,
sectionsProvider: sectionsProvider,
initialHighlight: match.$1.$2,
),
),
);
} else {
selected.value = match.$1.$1;
listController.value.animateToItem(
index: match.$1.$2,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => Durations.extralong1,
curve: (_) => Curves.easeInOut,
);
highlightedMatch.value = match.$1;
await Future.delayed(Durations.extralong4, () {
if (highlightedMatch.value == match.$1) {
highlightedMatch.value = null;
}
});
}
},
leading: Icon(match.$2.icon),
title: Text(match.$2.title),
subtitle: Text(match.$2.description),
),
);
},
);
final settingsContent = Scaffold(
appBar: AppBar(
title: Text(title),
actionsPadding: .symmetric(horizontal: 12),
actions: actions,
),
body: ref
.watch(sectionsProvider)
.betterWhen(
skipLoadingOnReload: true,
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12).copyWith(bottom: 8),
child: searchBar,
),
),
...sections
.mapTo(
(section, categories) => [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 4),
child: DividerText(section),
),
),
SliverM3ECardList(
padding: .symmetric(
horizontal: 4,
vertical: 8,
),
margin: .symmetric(horizontal: 12),
color: Theme.of(context)
.colorScheme
.primaryContainer,
itemCount: categories.length,
onTap: (index) => Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) =>
SettingsCategoryPage(
sections
.values
.flattenedToList
.indexOf(
categories[index],
),
sectionsProvider:
sectionsProvider,
),
),
),
itemBuilder: (context, index) => ListTile(
leading: Icon(categories[index].icon),
title: Text(categories[index].title),
),
),
],
)
.flattened,
],
)
: Row(
children: [
MaterialUiCompatibilityBridge(
child: NavigationRailM3E(
type: .alwaysExpand,
trailing: searchBar,
scrollable: true,
sections: sections
.mapTo(
(
categoryGroup,
categories,
) => NavigationRailM3ESection(
header: DividerText(categoryGroup),
destinations: categories
.map(
(category) =>
NavigationRailM3EDestination(
icon: Icon(category.icon),
label: category.title,
),
)
.toList(),
),
)
.toList(),
selectedIndex: selected.value,
onDestinationSelected: (value) =>
selected.value = value,
),
),
VerticalDivider(),
Expanded(
child: SuperListView(
listController: listController.value,
controller: scrollController,
padding: .symmetric(vertical: 12),
children: sections
.values
.flattenedToList[selected.value]
.settings
.mapIndexed(
(index, setting) => Padding(
padding: .only(bottom: 4),
child: HighlightWrapper(
setting.builder(
setting.title,
setting.description,
setting.icon,
),
isHighlighted:
highlightedMatch.value ==
(selected.value, index),
),
),
)
.toList(),
),
),
],
),
),
);
return constraints.maxWidth < 650
? settingsContent
: Dialog(
constraints: .loose(Size(900, 600)),
child: ClipRRect(
borderRadius: BorderRadiusGeometry.circular(12),
child: settingsContent,
),
);
},
),
);
}

View file

@ -0,0 +1,70 @@
import "dart:async";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const TextSettingTile({
required final Icon icon,
required final String title,
required final String description,
required final String? value,
/// Disables the tile if null, and while a returned future runs.
required final FutureOr<void> Function(String value)? onChanged,
final bool multiline = false,
super.key,
}) extends HookWidget {
@override
Widget build(BuildContext context) {
final saving = Saving.use();
Future<void> change(String value) => saving.run(() => onChanged!(value));
return ListTile(
enabled: onChanged != null && !saving.active,
leading: icon,
title: Text(title),
subtitle: Text(
value?.isNotEmpty == true ? value! : description,
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final controller = useTextEditingController(text: value);
void save() {
Navigator.of(context).pop();
change(controller.text.trim());
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(onPressed: save, child: Text("Save")),
],
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: .sentences,
minLines: 1,
maxLines: multiline ? 6 : 1,
onSubmitted: multiline ? null : (_) => save(),
decoration: .new(labelText: title),
),
],
);
},
),
),
);
}
}

View file

@ -9,22 +9,34 @@ class const SheetLayout({
@override
Widget build(BuildContext context) => Padding(
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SingleChildScrollView(
padding: const .all(24),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
spacing: 12,
children: [
if (title case final title?)
DefaultTextStyle(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (title case final title?)
Padding(
padding: const .fromLTRB(24, 24, 24, 0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.headlineSmall!,
child: title,
),
...children,
Row(mainAxisAlignment: .end, spacing: 8, children: actions),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
spacing: 12,
children: children,
),
),
),
Padding(
padding: const .fromLTRB(24, 0, 24, 24),
child: Row(mainAxisAlignment: .end, spacing: 8, children: actions),
),
],
),
);
}

View file

@ -14,6 +14,7 @@ import "package:nexus/models/content/create.dart";
import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/knock_sheet.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
),
child: const Text("Leave"),
)
: room.summary.mustKnock(joinedIds)
? TextButton(
onPressed: () => context.showAdaptiveSheet(
(_) =>
KnockSheet(room.summary.roomId, name: name, via: child.via),
),
child: const Text("Knock"),
)
: TextButton(
onPressed: () => context.joinRoom(
.new(roomIdOrAlias: room.summary.roomId, via: child.via),

View file

@ -1,4 +1,5 @@
import "package:collection/collection.dart";
import "package:dynamic_color/dynamic_color.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@ -7,14 +8,18 @@ import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/mutual_rooms.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/profile.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/create_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/mutual_rooms_sheet.dart";
import "package:nexus/models/membership_action.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart";
@ -22,7 +27,6 @@ import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/start_dm_sheet.dart";
final class const UserBottomSheet(
final MembershipContent member,
final String userId, {
final String? roomId,
super.key,
@ -35,6 +39,11 @@ final class const UserBottomSheet(
color: theme.colorScheme.onSurfaceVariant,
);
final client = ref.read(ClientController.provider.notifier);
// Green for accepting knocks, harmonized so it fits the current theme
final acceptScheme = ColorScheme.fromSeed(
seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary),
brightness: theme.brightness,
);
void showMembershipSheet(MembershipAction action) =>
context.showAdaptiveSheet(
@ -82,181 +91,248 @@ final class const UserBottomSheet(
),
);
return SingleChildScrollView(
child: Padding(
padding: .all(42),
child: Column(
spacing: 4,
mainAxisSize: .min,
crossAxisAlignment: .center,
children: [
Row(
mainAxisAlignment: .end,
children: [
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
SizedBox(height: 18),
ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
dimension: 200,
),
),
SizedBox(height: 8),
SelectableText(
member.displayName ?? userId.localpart,
style: textTheme.headlineLarge,
textAlign: .center,
),
SelectableText(userId, textAlign: .center, style: subtitleStyle),
ref
.watch(ProfileController.provider(userId))
.betterWhen(
loading: () => Text(""),
data: (profileResponse) => Column(
children: [
if (profileResponse.profile.timezone == null &&
profileResponse.profile.pronouns.isEmpty)
Text(""),
Wrap(
crossAxisAlignment: .center,
alignment: .center,
spacing: 4,
runSpacing: 4,
children: [
...profileResponse.profile.pronouns
.where(
// TODO: Check system language (l10n)
(pronoun) => pronoun.language == "en",
)
.mapIndexed(
(index, pronoun) => [
if (index != 0)
Icon(
Icons.circle,
size: 4,
color: theme.colorScheme.onSurfaceVariant,
),
Text(
pronoun.summary,
textAlign: .center,
style: subtitleStyle,
),
],
)
.flattened,
if (profileResponse.profile.timezone != null) ...[
if (profileResponse.profile.pronouns.isNotEmpty)
SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
profileResponse.profile.timezone!,
textAlign: .center,
style: subtitleStyle,
),
],
],
),
],
),
),
SizedBox(height: 8),
if (userId !=
ref.watch(ClientStateController.provider)?.userId) ...[
return switch (ref.watch(
UserController.provider(.new(roomId: roomId, userId: userId)),
)) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
AsyncData(value: final member) ||
AsyncLoading(value: final member?) => SingleChildScrollView(
child: Padding(
padding: .all(42),
child: Column(
spacing: 4,
mainAxisSize: .min,
crossAxisAlignment: .center,
children: [
Row(
mainAxisAlignment: .end,
children: [
Expanded(
child: M3EButton.icon(
onPressed: () async {
final name = member.displayName ?? userId.localpart;
final hasDm = ref
.read(RoomsController.provider)
.values
.any((room) => room.metadata?.dmUserId == userId);
if (hasDm) {
context.startDirectMessage(userId, name: name);
} else if (await context.showAdaptiveSheet<bool>(
(_) => StartDmSheet(userId: userId, name: name),
) !=
true) {
return;
}
if (context.mounted) Navigator.of(context).pop();
},
shape: .square,
style: .tonal,
icon: Icon(Icons.message),
label: Text("Message"),
),
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
SizedBox(height: 18),
if (roomId != null &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .kick,
roomId: roomId!,
targetUser: userId,
ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
dimension: 200,
),
),
SizedBox(height: 8),
SelectableText(
member.displayName ?? userId.localpart,
style: textTheme.headlineLarge,
textAlign: .center,
),
SelectableText(userId, textAlign: .center, style: subtitleStyle),
ref
.watch(ProfileController.provider(userId))
.betterWhen(
loading: () => Text(""),
data: (profileResponse) => Column(
children: [
Wrap(
crossAxisAlignment: .center,
alignment: .center,
spacing: 4,
runSpacing: 4,
children: [
...profileResponse.profile.pronouns
.where(
// TODO: Check system language (l10n)
(pronoun) => pronoun.language == "en",
)
.mapIndexed(
(index, pronoun) => [
if (index != 0)
Icon(
Icons.circle,
size: 4,
color:
theme.colorScheme.onSurfaceVariant,
),
Text(
pronoun.summary,
textAlign: .center,
style: subtitleStyle,
),
],
)
.flattened,
if (profileResponse.profile.timezone != null) ...[
if (profileResponse.profile.pronouns.isNotEmpty)
SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
profileResponse.profile.timezone!,
textAlign: .center,
style: subtitleStyle,
),
],
],
),
],
),
),
if (userId != ref.watch(ClientStateController.provider)?.userId)
switch (ref.watch(MutualRoomsController.provider(userId))) {
AsyncData(value: final roomIds) => switch (ref.watch(
RoomsController.provider.select(
(rooms) => roomIds.where(rooms.containsKey).length,
),
)) {
0 => SizedBox.shrink(),
final count => TextButton(
onPressed: () => context.showAdaptiveSheet(
(_) => MutualRoomsSheet(userId),
),
child: Text(
"$count mutual ${Intl.plural(count, one: "room", other: "rooms")}",
),
),
))
Padding(
padding: .only(top: 4),
child: Row(
spacing: 8,
children: [
if (member.status == .join || member.status == .invite)
M3EButton.icon(
onPressed: () => showMembershipSheet(.kick),
shape: .square,
icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
),
},
_ => SizedBox.shrink(),
},
M3EButton.icon(
onPressed: () => showMembershipSheet(.ban),
SizedBox(height: 8),
if (userId !=
ref.watch(ClientStateController.provider)?.userId) ...[
Row(
children: [
Expanded(
child: M3EButton.icon(
onPressed: () async {
final name = member.displayName ?? userId.localpart;
final hasDm = ref
.read(RoomsController.provider)
.values
.any((room) => room.metadata?.dmUserId == userId);
if (hasDm) {
context.startDirectMessage(userId, name: name);
} else if (await context.showAdaptiveSheet<bool>(
(_) => StartDmSheet(userId: userId, name: name),
) !=
true) {
return;
}
if (context.mounted) Navigator.of(context).pop();
},
shape: .square,
icon: Icon(Icons.gavel),
label: Text("Ban"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
style: .tonal,
icon: Icon(Icons.message),
label: Text("Message"),
),
),
],
),
if (roomId != null &&
member.status == .knock &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .invite,
roomId: roomId!,
targetUser: userId,
),
),
].map((e) => Expanded(child: e)).toList(),
))
Padding(
padding: .only(top: 4),
child: Row(
children: [
Expanded(
child: M3EButton.icon(
onPressed: () => client
.setMembership(
.new(
userId: userId,
roomId: roomId!,
action: .invite,
),
)
.onError(showError),
shape: .square,
icon: Icon(Icons.check),
label: Text("Accept knock"),
decoration: .styleFrom(
backgroundColor: acceptScheme.primary,
foregroundColor: acceptScheme.onPrimary,
),
),
),
],
),
),
),
if (roomId != null &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .kick,
roomId: roomId!,
targetUser: userId,
),
),
))
Padding(
padding: .only(top: 4),
child: Row(
spacing: 8,
children: [
if (member.status case .join || .invite || .knock)
M3EButton.icon(
onPressed: () => showMembershipSheet(.kick),
shape: .square,
icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
),
M3EButton.icon(
onPressed: () => showMembershipSheet(.ban),
shape: .square,
icon: Icon(Icons.gavel),
label: Text("Ban"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
),
),
].map((e) => Expanded(child: e)).toList(),
),
),
],
],
],
),
),
),
);
AsyncLoading _ => Loading(),
};
}
}

View file

@ -12,9 +12,7 @@
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) app_links_linux_registrar =
@ -35,13 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}

View file

@ -9,9 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux
media_kit_video
pasteboard
screen_retriever_linux
url_launcher_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -36,7 +36,13 @@ pkgs.mkShell {
})
android.platform-tools
]
++ lib.optional pkgs.stdenv.isLinux wayland;
++ lib.optionals pkgs.stdenv.isLinux [
wayland
# nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately
libxext
libxfixes
(callPackage ./pkg/hook_pkg_config.nix { })
];
env = rec {
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];

View file

@ -3,6 +3,8 @@
callPackage,
mpv-unwrapped,
libass,
libxext,
libxfixes,
libclang,
flutter,
stdenv,
@ -25,9 +27,14 @@ flutter.buildFlutterApplication {
"--dart-define=GIT_COMMIT=${commit}"
];
nativeBuildInputs = [ (callPackage ./hook_pkg_config.nix { }) ];
buildInputs = [
mpv-unwrapped
libass
# nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately
libxext
libxfixes
];
env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ];
@ -42,9 +49,10 @@ flutter.buildFlutterApplication {
m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";
native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY=";
navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";
unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4=";
unifiedpush_platform_interface = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4=";
webcrypto = "sha256-HW0NukbddwOYd9iVUFA3L8iy1qggnyOBgcbMj0MTYss=";
window_manager = "sha256-xUd5hG+/V6F+m0ZUL/3XGPACFG/zqI5JM6IaMVvLgx4=";
xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc=";
};

View file

@ -0,0 +1,32 @@
{
stdenvNoCC,
pkg-config,
pkg-config-unwrapped,
makeWrapper,
gtk3,
libx11,
libxi,
}:
# Build hooks run with a filtered environment that drops PKG_CONFIG_PATH, so
# nativeapi's hook can only find its libraries through a pkg-config on PATH.
stdenvNoCC.mkDerivation {
name = "hook-pkg-config";
dontUnpack = true;
nativeBuildInputs = [
pkg-config
makeWrapper
];
buildInputs = [
gtk3
libx11
libxi
];
installPhase = ''
makeWrapper ${pkg-config-unwrapped}/bin/pkg-config $out/bin/pkg-config \
--prefix PKG_CONFIG_PATH : "$PKG_CONFIG_PATH"
'';
}

View file

@ -155,11 +155,6 @@ static void my_application_activate(
}
#endif
gtk_widget_set_size_request(
GTK_WIDGET(window),
250,
-1);
if (use_header_bar) {
GtkHeaderBar* header_bar =
GTK_HEADER_BAR(

View file

@ -13,11 +13,9 @@ import media_kit_libs_macos_video
import media_kit_video
import package_info_plus
import pasteboard
import screen_retriever_macos
import shared_preferences_foundation
import url_launcher_macos
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
@ -28,9 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

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