Compare commits

..
110 changed files with 930 additions and 4799 deletions

View file

@ -49,7 +49,7 @@ nexus = {
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
```nix ```nix
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0"; url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0-alpha5";
``` ```
Then import the module and enable it in your NixOS configuration: Then import the module and enable it in your NixOS configuration:

View file

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

View file

@ -1,40 +0,0 @@
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 53cae0e0334aef5ac376ea84f19ba1cff8a92310 Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f

View file

@ -8,7 +8,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:flutter/cupertino.dart"; import "package:flutter/cupertino.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/capabilities.dart"; import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -42,7 +41,6 @@ import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.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_event.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart"; import "package:nexus/models/requests/set_account_data.dart";
@ -107,50 +105,39 @@ class ClientController extends AsyncNotifier<int> {
return handle; return handle;
} }
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod( Future<dynamic> callGomuksMethod(
Map<String, dynamic> data, Map<String, dynamic> data,
void Function( FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
int handle, callback,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async { ) async {
final handle = await future; final bufferPointer = data.toGomuksBufferPtr();
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
late final NativeCallable<Void Function(GomuksResponse)> callable;
callable = .listener((GomuksResponse response) {
callable.close();
try { try {
final handle = await future;
final response = await Isolate.run(
() => callback(handle, bufferPointer.ref),
);
final json = response.buf.toJsonAndFree(); final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") { if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString()); throw GomuksException(json.toString());
} }
completer.complete(json); return json;
} catch (error, stackTrace) { } finally {
completer.completeError(error, stackTrace); calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
} }
});
try {
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 { Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(data, GomuksHandlePushAsync); final response = await callGomuksMethod(
data,
(handle, data) async => GomuksHandlePush(handle, data),
);
return response?["notification"] == null return response?["notification"] == null
? null ? null
@ -160,12 +147,11 @@ class ClientController extends AsyncNotifier<int> {
Future<dynamic> _sendCommand( Future<dynamic> _sendCommand(
String command, [ String command, [
Map<String, dynamic> data = const {}, Map<String, dynamic> data = const {},
]) => callGomuksMethod(data, (handle, data, callback) { ]) => callGomuksMethod(data, (handle, data) {
final commandPointer = command.toNativeUtf8().cast<Char>(); final commandPointer = command.toNativeUtf8().cast<Char>();
try { try {
GomuksSubmitCommandAsync(handle, commandPointer, data, callback); return GomuksSubmitCommand(handle, commandPointer, data);
} finally { } finally {
// gomuks copies the command name before returning
calloc.free(commandPointer); calloc.free(commandPointer);
} }
}); });
@ -236,9 +222,6 @@ class ClientController extends AsyncNotifier<int> {
Future<String> joinRoom(JoinRoomRequest request) async => Future<String> joinRoom(JoinRoomRequest request) async =>
(await _sendCommand("join_room", request.toJson()))["room_id"]; (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 => Future<String> createRoom(CreateRoomRequest request) async =>
(await _sendCommand("create_room", request.toJson()))["room_id"]; (await _sendCommand("create_room", request.toJson()))["room_id"];
@ -290,12 +273,6 @@ class ClientController extends AsyncNotifier<int> {
Future<PaginateManual> paginateManual(PaginateManualRequest request) async => Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson())); .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 => Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson())); .fromJson(await _sendCommand("get_event_context", request.toJson()));
@ -307,15 +284,6 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<void> setProfileField(String field, Object? value) => _sendCommand(
"set_profile_field",
{
"field": field,
// gomuks deletes the field when the value is omitted, not when it's null
"value": ?value,
},
);
Future<void> reportEvent(ReportRequest request) => Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson()); _sendCommand("report_event", request.toJson());
@ -342,11 +310,6 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout"); 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 { Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return; if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
@ -354,14 +317,9 @@ class ClientController extends AsyncNotifier<int> {
if (event == null) return; 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", { await _sendCommand("mark_read", {
"room_id": room.metadata!.id, "room_id": room.metadata!.id,
"receipt_type": sendPublic ? "m.read" : "m.read.private", "receipt_type": "m.read",
"event_id": event.eventId, "event_id": event.eventId,
}); });
} }
@ -380,34 +338,6 @@ class ClientController extends AsyncNotifier<int> {
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async => Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
await _sendCommand("oauth_exchange_token", request.toJson()); 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 => Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions")); .fromJson(await _sendCommand("get_versions"));

View file

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

View file

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

View file

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

View file

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

View file

@ -1,20 +0,0 @@
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

@ -10,15 +10,6 @@ class ProfileController(final String userId)
return client.getProfile(userId); return client.getProfile(userId);
} }
Future<void> setField(String field, Object? value) async {
await ref
.read(ClientController.provider.notifier)
.setProfileField(field, value);
if (!ref.mounted) return;
ref.invalidateSelf();
await future;
}
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<ProfileController, ProfileResponse, String>( .autoDispose<ProfileController, ProfileResponse, String>(
ProfileController.new, ProfileController.new,

View file

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

View file

@ -0,0 +1,30 @@
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

@ -1,59 +0,0 @@
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

@ -1,826 +0,0 @@
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 (:state, :metadataTopic, :metadataAvatar) = ref.watch(
RoomsController.provider.select((rooms) {
final room = rooms[roomId];
return (
state: room?.state ?? const IMap.empty(),
metadataTopic: room?.metadata?.topic,
metadataAvatar: room?.metadata?.avatar,
);
}),
);
Content? contentOf(int rowId) =>
ref.read(RoomsController.provider)[roomId]?.events[rowId]?.content;
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 (state[type.type]?[""]) {
final rowId? => contentOf(rowId),
null => null,
};
final name = switch (stateContent(.name)) {
NameContent(:final name) => name,
_ => null,
};
final topic = switch (stateContent(.topic)) {
TopicContent(:final topic) => topic,
_ => metadataTopic,
};
final avatar = switch (stateContent(.avatar)) {
AvatarContent(:final url) => url,
_ => metadataAvatar,
};
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(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 (state[EventType.membership.type]?[userId]) {
final rowId? => switch (contentOf(rowId)) {
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

@ -1,34 +0,0 @@
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,10 +1,7 @@
import "dart:async";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> { class RoomsController extends Notifier<IMap<String, Room>> {
@ -36,49 +33,9 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(), .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( void update(
IMap<String, Room> rooms, [ IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(), ISet<String> leftRooms = const ISet.empty(),
]) { ]) {
final merged = rooms.entries.fold(state, (acc, entry) { final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key; final roomId = entry.key;
@ -119,42 +76,21 @@ class RoomsController extends Notifier<IMap<String, Room>> {
timeline: incoming.reset timeline: incoming.reset
? incoming.timeline ? incoming.timeline
: existing.timeline.addAll(incoming.timeline), : existing.timeline.addAll(incoming.timeline),
receipts: mergeReceipts(existing.receipts, incoming.receipts), receipts: incoming.receipts.entries.fold(
) ?? existing.receipts,
incoming.copyWith( (receiptAcc, event) => receiptAcc.add(
receipts: mergeReceipts(const .empty(), incoming.receipts), event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value),
), ),
),
) ??
incoming,
); );
}); });
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId)); 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>>( static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
RoomsController.new, RoomsController.new,
); );

View file

@ -18,14 +18,10 @@ import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/room_order.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/settings_category.dart";
import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -35,30 +31,6 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); final settings = await ref.watch(SettingsController.provider.future);
/// 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({ return .new({
"General": .new([ "General": .new([
.new( .new(
@ -179,7 +151,7 @@ class SettingsSectionsController
), ),
.new( .new(
title: "Linux Mobile Mode", title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.", description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
icon: Icons.construction, icon: Icons.construction,
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),
@ -194,76 +166,12 @@ class SettingsSectionsController
: null, : 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),
),
),
]), ]),
), ),
]), ]),
if (ref.watch(ClientStateController.provider) case ClientState( if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
isLoggedIn: true,
:final userId?,
))
"Account": .new([ "Account": .new([
.new( .new(title: "Profile", icon: Icons.person, settings: .new([])),
title: "Profile",
icon: Icons.person,
settings: .new([
.new(
title: "Profile",
description: "Your avatar, display name, pronouns, and timezone, as others see them.",
icon: Icons.person,
builder: (title, description, icon) => Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 500),
child: UserBottomSheet(userId, editable: true),
),
),
),
]),
),
.new( .new(
title: "Notifications", title: "Notifications",
icon: Icons.notifications, icon: Icons.notifications,

View file

@ -1,17 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:timezone/data/latest_10y.dart";
import "package:timezone/timezone.dart";
/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically.
class TimezonesController extends Notifier<IList<String>> {
@override
IList<String> build() {
initializeTimeZones();
return timeZoneDatabase.locations.keys.toIList().sort();
}
static final provider = NotifierProvider<TimezonesController, IList<String>>(
TimezonesController.new,
);
}

View file

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

View file

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

View file

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

View file

@ -1,15 +0,0 @@
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,29 +40,4 @@ extension JoinRoom on BuildContext {
return roomId; 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,8 +1,14 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_popover.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"; import "package:nexus/widgets/user_bottom_sheet.dart";
extension ShowUserPopover on BuildContext { extension ShowUserPopover on BuildContext {
void showUserPopover(String userId, {String? roomId}) => void showUserPopover(
showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); MembershipContent member,
String userId, {
String? roomId,
}) => showAdaptivePopover(
(context) => UserBottomSheet(member, userId, roomId: roomId),
);
} }

View file

@ -1,25 +0,0 @@
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,10 +83,14 @@ void main(List<String> args) async {
exit(0); exit(0);
} else { } else {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
WindowManager.instance.getCurrent() await windowManager.ensureInitialized();
?..titleBarStyle = .hidden await windowManager.waitUntilReadyToShow(
..isWindowControlButtonsVisible = false WindowOptions(
..minimumSize = Size.square(300).toNative(); titleBarStyle: TitleBarStyle.hidden,
windowButtonVisibility: false,
),
);
await windowManager.setMinimumSize(Size.square(500));
} }
runApp( runApp(
@ -107,21 +111,10 @@ class const App({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => DynamicColorBuilder( Widget build(BuildContext context) => DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) => Consumer( builder: (lightDynamic, darkDynamic) => Consumer(
builder: (context, ref, child) { builder: (context, ref, child) => MaterialApp(
// 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, navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: forPlatform( theme:
(ref (ref
.watch(SettingsController.provider) .watch(SettingsController.provider)
.maybeWhen( .maybeWhen(
@ -131,8 +124,7 @@ class const App({super.key}) extends StatelessWidget {
) ?? ) ??
ThemeData.light().colorScheme) ThemeData.light().colorScheme)
.theme, .theme,
), darkTheme:
darkTheme: forPlatform(
(ref (ref
.watch(SettingsController.provider) .watch(SettingsController.provider)
.maybeWhen( .maybeWhen(
@ -142,7 +134,6 @@ class const App({super.key}) extends StatelessWidget {
) ?? ) ??
ThemeData.dark().colorScheme) ThemeData.dark().colorScheme)
.theme, .theme,
),
themeMode: ref themeMode: ref
.watch(SettingsController.provider) .watch(SettingsController.provider)
.maybeWhen( .maybeWhen(
@ -150,8 +141,7 @@ class const App({super.key}) extends StatelessWidget {
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
), ),
home: child, home: child,
); ),
},
child: DefaultTextHeightBehavior( child: DefaultTextHeightBehavior(
textHeightBehavior: .new(leadingDistribution: .even), textHeightBehavior: .new(leadingDistribution: .even),
child: Scaffold( child: Scaffold(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,41 +31,24 @@ class const Bio({required final String html, final String? editSource})
class const Profile({ class const Profile({
final String? id, final String? id,
final String? parseError, final String? parseError,
@JsonKey(name: Profile.avatarUrlKey) final Uri? avatarUrl, final Uri? avatarUrl,
@JsonKey( @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson)
name: Profile.displayNameKey,
fromJson: MembershipContent.displaynameFromJson,
)
final String? displayName, final String? displayName,
@JsonKey(name: Profile.timezoneKey) final String? timezone, @JsonKey(readValue: Profile.readTimezone, name: "m.tz")
final String? timezone,
@JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey) @JsonKey(readValue: Profile.readPronouns, name: "m.pronouns")
final IList<Pronoun> pronouns = const .empty(), final IList<Pronoun> pronouns = const IList.empty(),
}) with _$Profile { }) with _$Profile {
static const avatarUrlKey = "avatar_url";
static const displayNameKey = "displayname";
static const timezoneKey = "m.tz";
static const pronounsKey = "m.pronouns";
static const unstablePronounsKey = "io.fsky.nyx.pronouns";
Map<String, Object?> toJson() => _$ProfileToJson(this); Map<String, Object?> toJson() => _$ProfileToJson(this);
Profile withPronouns(String language, Iterable<String> summaries) => copyWith(
pronouns: pronouns
.where((pronoun) => pronoun.language != language)
.toIList()
.addAll(
summaries.map(
(summary) => Pronoun(language: language, summary: summary),
),
),
);
static Object? readPronouns(Map<dynamic, dynamic> map, String key) => static Object? readPronouns(Map<dynamic, dynamic> map, String key) =>
/* until stable, commented out: map[key] ?? */ map[Profile map[key] ?? map["io.fsky.nyx.pronouns"];
.unstablePronounsKey];
static Object? readTimezone(Map<dynamic, dynamic> map, String key) =>
map[key] ?? map["us.cloke.msc4175.tz"];
factory Profile.fromJson(Map<String, dynamic> json) => factory Profile.fromJson(Map<String, dynamic> json) =>
_$ProfileFromJson(json); _$ProfileFromJson(json);

View file

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

View file

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

View file

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

View file

@ -1,21 +0,0 @@
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() @JsonSerializable()
class const Mentions({ class const Mentions({
final bool room = false, final bool room = false,
final IList<String> userIds = const .empty(), final IList<String> userIds = const IList.empty(),
}) with _$Mentions { }) with _$Mentions {
Map<String, Object?> toJson() => _$MentionsToJson(this); Map<String, Object?> toJson() => _$MentionsToJson(this);

View file

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

View file

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

View file

@ -1,11 +0,0 @@
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,4 +1,3 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
@ -15,9 +14,6 @@ class const RoomSummary({
required final String roomId, required final String roomId,
@JsonKey(name: "num_joined_members") final int? joinedMembers, @JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule, final JoinRule? joinRule,
/// Rooms whose members may join without an invite, for restricted rooms.
final IList<String> allowedRoomIds = const .empty(),
final String? name, final String? name,
final Uri? avatarUrl, final Uri? avatarUrl,
final String? canonicalAlias, final String? canonicalAlias,
@ -45,14 +41,6 @@ class const RoomSummary({
/// Whether this is an invite to a direct message. /// Whether this is an invite to a direct message.
@JsonKey(includeFromJson: false) final bool isDirect = false, @JsonKey(includeFromJson: false) final bool isDirect = false,
}) with _$RoomSummary { }) 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. /// A summary of a room the user is in, from its synced metadata.
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new( factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
roomId: metadata.id, roomId: metadata.id,

View file

@ -1,7 +1,6 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/show_and_send.dart";
part "settings.freezed.dart"; part "settings.freezed.dart";
part "settings.g.dart"; part "settings.g.dart";
@ -15,13 +14,7 @@ class const Settings({
final bool showAllInHome = false, final bool showAllInHome = false,
final bool showMinimizeButton = false, final bool showMinimizeButton = false,
final bool swipeToReply = false, final bool swipeToReply = false,
final bool hideMembershipEvents = false,
final RoomOrder roomOrder = .unreadFirst, 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 { }) with _$Settings {
Map<String, Object?> toJson() => _$SettingsToJson(this); Map<String, Object?> toJson() => _$SettingsToJson(this);

View file

@ -1,2 +0,0 @@
/// 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) @JsonSerializable(createToJson: false)
class const SyncData({ class const SyncData({
final bool clearState = false, final bool clearState = false,
final IMap<String, IMap<String, dynamic>> accountData = const .empty(), final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
final IMap<String, Room> rooms = const .empty(), final IMap<String, Room> rooms = const IMap.empty(),
final ISet<String> leftRooms = const .empty(), final ISet<String> leftRooms = const ISet.empty(),
final IList<InvitedRoom> invitedRooms = const .empty(), final IList<InvitedRoom> invitedRooms = const IList.empty(),
final IMap<String, IList<SpaceEdge>>? spaceEdges, final IMap<String, IList<SpaceEdge>>? spaceEdges,
final IList<String>? topLevelSpaces, final IList<String>? topLevelSpaces,
}) with _$SyncData { }) with _$SyncData {

View file

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

View file

@ -1,39 +0,0 @@
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

@ -1,41 +0,0 @@
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,8 +11,6 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/attachment.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/controllers/image_picker.dart";
import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/file_dialog_helper.dart";
import "package:image_picker/image_picker.dart"; import "package:image_picker/image_picker.dart";
@ -96,44 +94,6 @@ class const Composer(
return null; return null;
}, [relationType, relatedEvent == 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)); final attachment = ref.watch(AttachmentController.provider(roomId));
Future<void> attach(Future<XFile?> pick) async { Future<void> attach(Future<XFile?> pick) async {

View file

@ -1,113 +0,0 @@
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

@ -1,86 +0,0 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_adaptive_menu.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/message.dart";
/// Shows [child], an avatar, with a button to upload a new one or remove it.
class const EditableAvatar({
required final Widget child,
/// Whether there's an avatar to remove.
required final bool hasAvatar,
/// Called with the uploaded image, or null when the avatar is removed.
required final Future<void> Function(Uri? url) onChanged,
final double dimension = 200,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final saving = Saving.use();
Future<void> save(Future<void> Function() action) =>
saving.run(() => action().onError(showError));
Future<void> pick() async {
final file = await ref
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await save(() async {
final content = await ref
.read(ClientController.provider.notifier)
.uploadMedia(.new(path: file.path, encrypt: false));
if (content case ImageMessageContent(:final url)) {
await onChanged(url);
} else {
throw Exception("The file isn't an image");
}
});
}
return SizedBox.square(
dimension: dimension,
child: Stack(
children: [
child,
if (saving.active) const Center(child: CircularProgressIndicator()),
Positioned(
right: dimension * 0.01,
bottom: dimension * 0.01,
child: Builder(
builder: (context) => IconButton.filled(
tooltip: "Edit avatar",
icon: Icon(Icons.edit),
onPressed: saving.active
? null
: !hasAvatar
? pick
: () => context.showAdaptiveMenu([
PopupMenuItem(
onTap: pick,
child: ListTile(
leading: Icon(Icons.upload),
title: Text("Upload new avatar"),
),
),
PopupMenuItem(
onTap: () => save(() => onChanged(null)),
child: ListTile(
leading: Icon(Icons.delete),
title: Text("Remove avatar"),
),
),
]),
),
),
),
],
),
);
}
}

View file

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

View file

@ -1,51 +0,0 @@
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,11 +12,12 @@ class const MessageAvatar(
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
switch (ref.watch(AuthorController.provider(event))) { AuthorController.provider(event),
)) {
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTap: () => onTap: () =>
context.showUserPopover(event.sender, roomId: event.roomId), context.showUserPopover(value, event.sender, roomId: event.roomId),
child: AvatarOrHash( child: AvatarOrHash(
value.avatarUrl, value.avatarUrl,
value.displayName ?? event.sender.localpart, value.displayName ?? event.sender.localpart,

View file

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

View file

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

View file

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

View file

@ -1,72 +0,0 @@
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,19 +1,247 @@
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.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:nexus/controllers/settings_sections.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/helpers/extensions/show_about_sheet.dart";
import "package:nexus/widgets/settings/settings_layout.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";
class const SettingsPage({super.key}) extends ConsumerWidget { class const SettingsPage({super.key}) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout( Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
title: "Settings", builder: (_, constraints) => HookBuilder(
sectionsProvider: SettingsSectionsController.provider, 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: [ actions: [
IconButton( IconButton(
onPressed: () => context.showAboutSheet(ref), onPressed: () => context.showAboutSheet(ref),
icon: Icon(Icons.info_outline), 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,
),
);
},
),
); );
} }

View file

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

View file

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

View file

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

View file

@ -1,112 +0,0 @@
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

@ -1,54 +0,0 @@
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/event.dart";
import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.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/extensions/show_adaptive_menu.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encrypted.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/pinned_events.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/server_acl.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/content/topic.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/renderers/message.dart";
import "package:nexus/widgets/reaction_row.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/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/swipe_to_reply.dart";
@ -43,10 +43,6 @@ class const EventRenderer(
/// If set, events can be swiped, calling this /// If set, events can be swiped, calling this
final VoidCallback? onSwipe, 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, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget {
@override @override
@ -82,24 +78,10 @@ class const EventRenderer(
if (event == null) return SizedBox.shrink(); 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 = final isGrouped =
(previousEvent?.isShownAsMessage ?? false) && previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender && event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id; event.pmp?.id == previousEvent?.pmp?.id;
@ -112,8 +94,21 @@ class const EventRenderer(
TextSpan(text: text), TextSpan(text: text),
]); ]);
final child = event.isShownAsMessage final child = event.redactedBy != null || event.relationType == "m.replace"
? MessageRenderer( ? null
: switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row(
children: [
ErrorDialog(
"An error occurred while parsing event ${event.eventId}:\n$parseError",
stackTrace,
),
],
),
MessageContent() ||
EncryptedContent() ||
StickerContent() => MessageRenderer(
event, event,
onTapReply: jumpToEvent == null onTapReply: jumpToEvent == null
? null ? null
@ -130,17 +125,6 @@ class const EventRenderer(
isGrouped: isGrouped, isGrouped: isGrouped,
maxLines: maxLines, maxLines: maxLines,
textOnly: textOnly, textOnly: textOnly,
)
: event.redactedBy != null || event.relationType == "m.replace"
? null
: switch (event.content) {
Content(:final parseError?, :final stackTrace) => Row(
children: [
ErrorDialog(
"An error occurred while parsing event ${event.eventId}:\n$parseError",
stackTrace,
),
],
), ),
MembershipContent content => switch (event.previousContent) { MembershipContent content => switch (event.previousContent) {
@ -284,10 +268,6 @@ class const EventRenderer(
...[ ...[
if (child is! MessageRenderer) ReactionRow(event), 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") if (event.sendError != null && event.sendError != "not sent")
Padding( Padding(
padding: .only(bottom: 4), padding: .only(bottom: 4),

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.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/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/renderers/event.dart";
@ -8,15 +7,12 @@ import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({ class const ChatTimeline({
required final ChatParams chat, required final String roomId,
required final ChatScroll scroll, required final ChatScroll scroll,
required final JumpToEvent jumpToEvent, required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent, required final int? highlightedEvent,
required final double composerHeight, 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, final void Function(int rowId)? onSwipe,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@ -28,11 +24,10 @@ class const ChatTimeline({
EventRenderer( EventRenderer(
eventRowId, eventRowId,
previousEventRowId: previousEventRowId, previousEventRowId: previousEventRowId,
roomId: chat.roomId, roomId: roomId,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
readReceiptsChat: chat,
), ),
key: key, key: key,
isHighlighted: highlightedEvent == eventRowId, isHighlighted: highlightedEvent == eventRowId,
@ -45,22 +40,7 @@ class const ChatTimeline({
keyboardDismissBehavior: .onDrag, keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController, controller: scroll.scrollController,
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
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( SuperSliverList.builder(
itemCount: scroll.liveRows.length, itemCount: scroll.liveRows.length,

View file

@ -15,13 +15,11 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.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/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.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/widgets/sync_status_banner.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart";
@ -47,7 +45,6 @@ final class const RoomChat({
final highlightedEvent = useState<int?>(null); final highlightedEvent = useState<int?>(null);
final composerSize = useState<double>(64); final composerSize = useState<double>(64);
final typingIndicatorSize = useState<double>(0);
final userId = ref.watch(ClientStateController.provider)?.userId; final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref final memberListOpened = ref
@ -85,11 +82,10 @@ final class const RoomChat({
), ),
); );
final ChatParams chat = ( final controllerProvider = RoomChatController.provider((
roomId: roomId, roomId: roomId,
contextualEvent: contextualEvent.value, contextualEvent: contextualEvent.value,
); ));
final controllerProvider = RoomChatController.provider(chat);
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
@ -202,15 +198,6 @@ final class const RoomChat({
); );
return Scaffold( return Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: SearchDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (searchContext) => Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: PinnedEventsDrawer( endDrawer: PinnedEventsDrawer(
roomId, roomId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
@ -229,9 +216,7 @@ final class const RoomChat({
.set(!memberListOpened); .set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer(); Scaffold.of(thisContext).openEndDrawer();
}, },
onOpenPinnedMessagesList: Scaffold.of(middleContext) onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
.openEndDrawer,
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
), ),
body: Column( body: Column(
children: [ children: [
@ -249,7 +234,7 @@ final class const RoomChat({
AsyncLoading(value: _?) || AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline( AsyncData(value: _) => ChatTimeline(
scroll: scroll, scroll: scroll,
chat: chat, roomId: roomId,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply onSwipe: swipeToReply && canReply
@ -257,30 +242,13 @@ final class const RoomChat({
: null, : null,
highlightedEvent: highlightedEvent.value, highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value, composerHeight: composerSize.value,
typingIndicatorHeight:
typingIndicatorSize.value,
), ),
AsyncLoading _ => Loading(), AsyncLoading _ => Loading(),
AsyncError( AsyncError(:final error, :final stackTrace) =>
:final error,
:final stackTrace,
) =>
ErrorDialog(error, 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( Positioned(
right: 16, right: 16,
bottom: composerSize.value, bottom: composerSize.value,
@ -291,9 +259,7 @@ final class const RoomChat({
duration: Durations.short4, duration: Durations.short4,
child: FloatingActionButton.small( child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom, onPressed: scroll.jumpToBottom,
child: const Icon( child: const Icon(Icons.keyboard_arrow_down),
Icons.keyboard_arrow_down,
),
), ),
), ),
), ),
@ -319,8 +285,7 @@ final class const RoomChat({
tags: tags, tags: tags,
relationType: relationType.value, relationType: relationType.value,
shouldMention: shouldMention, shouldMention: shouldMention,
relatedEventRowId: relatedEventRowId: relatedEvent.value,
relatedEvent.value,
) )
.onError(showError), .onError(showError),
relationType: relationType.value, relationType: relationType.value,
@ -345,8 +310,6 @@ final class const RoomChat({
), ),
), ),
), ),
),
),
); );
} }
} }

View file

@ -7,9 +7,7 @@ import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.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/adaptive_menu_button.dart";
import "package:nexus/widgets/settings/settings_layout.dart";
import "package:nexus/controllers/via.dart"; import "package:nexus/controllers/via.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
@ -17,7 +15,7 @@ import "package:nexus/main.dart";
final class const RoomMenuButton( final class const RoomMenuButton(
final Room? room, { final Room? room, {
final IList<Room> children = const .empty(), final IList<Room> children = const IList.empty(),
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
@ -124,21 +122,6 @@ final class const RoomMenuButton(
title: Text("Invite"), 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( PopupMenuItem(
onTap: () => context.showLeaveRoomSheet( onTap: () => context.showLeaveRoomSheet(
room!.metadata!.id, room!.metadata!.id,

View file

@ -2,16 +2,12 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/invites.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/get_localpart.dart";
import "package:nexus/helpers/extensions/join_room.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/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.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/widgets/linkified_text.dart";
import "package:nexus/models/room_summary.dart"; import "package:nexus/models/room_summary.dart";
@ -19,17 +15,11 @@ class const RoomSummarySheet(
final RoomSummary summary, { final RoomSummary summary, {
final IList<String>? via, final IList<String>? via,
super.key, super.key,
}) extends ConsumerWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
final isInvite = summary.membership == .invite; 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; final colorScheme = Theme.of(context).colorScheme;
return SheetLayout( return SheetLayout(
@ -93,35 +83,7 @@ class const RoomSummarySheet(
}, },
child: Text("Accept"), child: Text("Accept"),
), ),
] else if (isKnocking) ] else if (via != null)
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( TextButton(
onPressed: () { onPressed: () {
context.joinRoom( context.joinRoom(
@ -177,17 +139,6 @@ class const RoomSummarySheet(
subtitle: LinkifiedText(summary.topic!), subtitle: LinkifiedText(summary.topic!),
leading: Icon(Icons.info), 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) if (summary.encryption != null)
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
if (summary.worldReadable == true) if (summary.worldReadable == true)

View file

@ -1,147 +0,0 @@
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

@ -1,112 +0,0 @@
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

@ -1,78 +0,0 @@
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

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

View file

@ -1,131 +0,0 @@
import "dart:async";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/hooks/saving.dart";
/// Centered text that's edited in place, saving when submitted or unfocused.
class const InlineTextField({
required final String? value,
required final String hint,
/// Disables the field if null, and while a returned future runs.
required final FutureOr<void> Function(String value)? onChanged,
final TextStyle? style,
/// Pads the start to match the edit icon, keeping the text centered when the
/// field is on its own, rather than beside others.
final bool balanced = true,
super.key,
}) extends HookWidget {
static double iconSizeOf(TextStyle? style) =>
((style?.fontSize ?? 16) * 0.7).clamp(16.0, 24.0);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final saving = Saving.use();
final controller = useTextEditingController(text: value);
final isEmpty = useValueListenable(controller).text.isEmpty;
final focusNode = useFocusNode();
final focused = useListenableSelector(focusNode, () => focusNode.hasFocus);
final latest = useRef(value);
latest.value = value;
// Show changes made elsewhere, unless they'd overwrite an edit. Effects run
// while building, so this waits until after, to not rebuild the field mid-build.
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && !focusNode.hasFocus) {
controller.text = value ?? "";
}
});
return null;
}, [value]);
useEffect(() {
Future<void> listener() async {
if (focusNode.hasFocus) return;
final text = controller.text.trim();
if (text != (latest.value ?? "")) {
await saving.run(() => onChanged?.call(text));
}
if (context.mounted && !focusNode.hasFocus) {
controller.text = latest.value ?? "";
}
}
focusNode.addListener(listener);
return () => focusNode.removeListener(listener);
}, [focusNode, onChanged]);
const border = UnderlineInputBorder(borderRadius: .zero, borderSide: .none);
final iconSize = iconSizeOf(style);
final iconConstraints = BoxConstraints.tight(Size(iconSize + 12, iconSize));
return CallbackShortcuts(
bindings: {
const SingleActivator(.escape): () {
controller.text = value ?? "";
focusNode.unfocus();
},
},
// Sized to the text, so the edit icon sits beside it
child: IntrinsicWidth(
child: TextField(
controller: controller,
focusNode: focusNode,
enabled: onChanged != null && !saving.active,
style: style,
textAlign: .center,
textCapitalization: .sentences,
// Wraps long text, but stays one line, with enter saving it
maxLines: null,
keyboardType: .text,
textInputAction: .done,
inputFormatters: [FilteringTextInputFormatter.singleLineFormatter],
onSubmitted: (_) => focusNode.unfocus(),
onTapOutside: (_) => focusNode.unfocus(),
decoration: .new(
// The hint counts towards the field's width, so it's only set when it shows
hintText: isEmpty ? hint : null,
hintStyle: style?.copyWith(color: colorScheme.onSurfaceVariant),
filled: true,
fillColor: Colors.transparent,
hoverColor: colorScheme.surfaceContainerHighest,
isDense: true,
// Without the prefix, this keeps the text off the edge of the hover background
contentPadding: EdgeInsetsDirectional.only(
start: balanced ? 0 : 6,
top: 8,
bottom: 8,
),
border: border,
focusedBorder: border.copyWith(
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
prefixIcon: balanced ? const SizedBox() : null,
prefixIconConstraints: iconConstraints,
suffixIcon: saving.active
? Center(
child: SizedBox.square(
dimension: iconSize - 4,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.edit,
size: iconSize,
color: focused
? colorScheme.onSurfaceVariant.withValues(alpha: 0.38)
: colorScheme.onSurfaceVariant,
),
suffixIconConstraints: iconConstraints,
),
),
),
);
}
}

View file

@ -1,107 +0,0 @@
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

@ -1,55 +0,0 @@
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

@ -1,48 +0,0 @@
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 = (1 << 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

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

View file

@ -1,142 +0,0 @@
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

@ -1,256 +0,0 @@
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

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

View file

@ -14,7 +14,6 @@ import "package:nexus/models/content/create.dart";
import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.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/loading.dart";
import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
@ -48,14 +47,6 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
), ),
child: const Text("Leave"), 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( : TextButton(
onPressed: () => context.joinRoom( onPressed: () => context.joinRoom(
.new(roomIdOrAlias: room.summary.roomId, via: child.via), .new(roomIdOrAlias: room.summary.roomId, via: child.via),

View file

@ -1,102 +0,0 @@
import "dart:async";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/timezones.dart";
import "package:nexus/helpers/extensions/is_desktop.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:timezone/timezone.dart";
class const TimezoneField({
required final String? value,
required final FutureOr<void> Function(String? value)? onChanged,
final TextStyle? style,
super.key,
}) extends HookConsumerWidget {
static String offsetOf(String timezone) {
final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset;
final hours = offset.inHours.abs().toString().padLeft(2, "0");
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0");
return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes";
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final saving = Saving.use();
final timezones = ref.watch(TimezonesController.provider);
final enabled = onChanged != null && !saving.active;
final iconSize = InlineTextField.iconSizeOf(style);
void choose(SearchController controller, String? timezone) {
controller.closeView("");
if (timezone != value) saving.run(() => onChanged!(timezone));
}
return SearchAnchor(
viewHintText: "Search timezones...",
viewConstraints: context.isDesktop
? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400)
: null,
builder: (context, controller) => InkWell(
hoverColor: colorScheme.surfaceContainerHighest,
onTap: enabled ? controller.openView : null,
child: Padding(
padding: const .fromLTRB(6, 8, 0, 8),
child: Row(
mainAxisSize: .min,
children: [
Text(
value?.replaceAll("_", " ") ?? "Timezone",
style: value == null
? style?.copyWith(color: colorScheme.onSurfaceVariant)
: style,
),
SizedBox(
width: iconSize + 12,
child: saving.active
? Center(
child: SizedBox.square(
dimension: iconSize - 4,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.edit,
size: iconSize,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
suggestionsBuilder: (context, controller) {
final query = controller.text.trim().toLowerCase().replaceAll(" ", "_");
return [
if (value != null && query.isEmpty)
ListTile(
leading: Icon(Icons.close),
title: Text("Remove timezone"),
onTap: () => choose(controller, null),
),
...timezones
.where((timezone) => timezone.toLowerCase().contains(query))
.map(
(timezone) => ListTile(
leading: Icon(
timezone == value ? Icons.check : Icons.schedule,
),
title: Text(timezone.replaceAll("_", " ")),
subtitle: Text(offsetOf(timezone)),
onTap: () => choose(controller, timezone),
),
),
];
},
);
}
}

View file

@ -1,6 +1,4 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:dynamic_color/dynamic_color.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
@ -9,35 +7,24 @@ import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/mutual_rooms.dart";
import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/profile.dart";
import "package:nexus/controllers/rooms.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/better_when.dart";
import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/create_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.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/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_action.dart"; import "package:nexus/models/membership_action.dart";
import "package:nexus/models/profile_response.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/editable_avatar.dart";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:nexus/widgets/start_dm_sheet.dart"; import "package:nexus/widgets/start_dm_sheet.dart";
import "package:nexus/widgets/timezone_field.dart";
final class const UserBottomSheet( final class const UserBottomSheet(
final MembershipContent member,
final String userId, { final String userId, {
final String? roomId, final String? roomId,
/// Edits the current user's global profile, instead of showing actions.
final bool editable = false,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
@ -48,43 +35,6 @@ final class const UserBottomSheet(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
); );
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final profileController = ref.read(
ProfileController.provider(userId).notifier,
);
Future<void> setProfileField(String field, Object? value) =>
profileController.setField(field, value).onError(showError);
final profile = ref
.watch(ProfileController.provider(userId))
.value
?.profile;
final englishPronouns = (profile?.pronouns ?? const IList<Pronoun>.empty())
// TODO: Check system language (l10n)
.where((pronoun) => pronoun.language == "en");
final divider = SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget avatarOf(MembershipContent member) => ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
dimension: 200,
),
);
// 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) => void showMembershipSheet(MembershipAction action) =>
context.showAdaptiveSheet( context.showAdaptiveSheet(
@ -132,23 +82,14 @@ final class const UserBottomSheet(
), ),
); );
return switch (ref.watch( return SingleChildScrollView(
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( child: Padding(
padding: .all(editable ? 16 : 42), padding: .all(42),
child: Column( child: Column(
spacing: 4, spacing: 4,
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: .center, crossAxisAlignment: .center,
children: [ children: [
if (!editable) ...[
Row( Row(
mainAxisAlignment: .end, mainAxisAlignment: .end,
children: [ children: [
@ -159,33 +100,19 @@ final class const UserBottomSheet(
], ],
), ),
SizedBox(height: 18), SizedBox(height: 18),
],
if (editable) ExpandableImage(
EditableAvatar( member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
hasAvatar: member.avatarUrl != null, downloadFilename: member.displayName ?? userId.localpart,
onChanged: (url) => setProfileField( child: AvatarOrHash(
Profile.avatarUrlKey, member.avatarUrl,
url?.toString() ?? "", member.displayName ?? userId.localpart,
dimension: 200,
),
), ),
child: avatarOf(member),
)
else
avatarOf(member),
SizedBox(height: 8), SizedBox(height: 8),
if (editable)
InlineTextField(
value: profile?.displayName,
hint: userId.localpart,
style: textTheme.headlineLarge,
onChanged: profile == null
? null
: (value) =>
setProfileField(Profile.displayNameKey, value),
)
else
SelectableText( SelectableText(
member.displayName ?? userId.localpart, member.displayName ?? userId.localpart,
style: textTheme.headlineLarge, style: textTheme.headlineLarge,
@ -196,87 +123,30 @@ final class const UserBottomSheet(
ref ref
.watch(ProfileController.provider(userId)) .watch(ProfileController.provider(userId))
.betterWhen( .betterWhen(
// Keeps showing the profile while it's fetched again after saving
skipLoadingOnRefresh: true,
loading: () => Text(""), loading: () => Text(""),
data: (profileResponse) => Column( data: (profileResponse) => Column(
children: [ children: [
if (profileResponse.profile.timezone == null &&
profileResponse.profile.pronouns.isEmpty)
Text(""),
Wrap( Wrap(
crossAxisAlignment: .center, crossAxisAlignment: .center,
alignment: .center, alignment: .center,
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: editable children: [
? [ ...profileResponse.profile.pronouns
InlineTextField(
value: englishPronouns
.map((pronoun) => pronoun.summary)
.join(", "),
hint: "Pronouns, e.g. they/them",
balanced: false,
style: subtitleStyle,
onChanged: (value) {
final pronouns = profileResponse
.profile
.pronouns
.where( .where(
(pronoun) =>
// TODO: Check system language (l10n) // TODO: Check system language (l10n)
pronoun.language != "en", (pronoun) => pronoun.language == "en",
) )
.toIList()
.addAll(
value
.split(",")
.map(
(summary) => summary.trim(),
)
.where(
(summary) =>
summary.isNotEmpty,
)
.map(
(summary) => Pronoun(
language: "en",
summary: summary,
),
),
);
return setProfileField(
Profile.unstablePronounsKey,
pronouns.isEmpty
? null
: pronouns
.map(
(pronoun) =>
pronoun.toJson(),
)
.toList(),
);
},
),
divider,
TimezoneField(
value: profileResponse.profile.timezone,
style: subtitleStyle,
onChanged: (value) => setProfileField(
Profile.timezoneKey,
value,
),
),
]
: [
...englishPronouns
.mapIndexed( .mapIndexed(
(index, pronoun) => [ (index, pronoun) => [
if (index != 0) if (index != 0)
Icon( Icon(
Icons.circle, Icons.circle,
size: 4, size: 4,
color: theme color: theme.colorScheme.onSurfaceVariant,
.colorScheme
.onSurfaceVariant,
), ),
Text( Text(
pronoun.summary, pronoun.summary,
@ -287,13 +157,16 @@ final class const UserBottomSheet(
) )
.flattened, .flattened,
if (profileResponse.profile.timezone != if (profileResponse.profile.timezone != null) ...[
null) ...[ if (profileResponse.profile.pronouns.isNotEmpty)
if (profileResponse SizedBox(
.profile height: 16,
.pronouns child: VerticalDivider(
.isNotEmpty) thickness: 1.5,
divider, width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
),
Text( Text(
profileResponse.profile.timezone!, profileResponse.profile.timezone!,
textAlign: .center, textAlign: .center,
@ -306,67 +179,30 @@ final class const UserBottomSheet(
), ),
), ),
if (!editable &&
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")}",
),
),
},
_ => SizedBox.shrink(),
},
SizedBox(height: 8), SizedBox(height: 8),
if (editable || if (userId !=
userId !=
ref.watch(ClientStateController.provider)?.userId) ...[ ref.watch(ClientStateController.provider)?.userId) ...[
Row( Row(
children: [ children: [
Expanded( Expanded(
child: M3EButton.icon( child: M3EButton.icon(
onPressed: editable onPressed: () async {
? null final name = member.displayName ?? userId.localpart;
: () async {
final name =
member.displayName ?? userId.localpart;
final hasDm = ref final hasDm = ref
.read(RoomsController.provider) .read(RoomsController.provider)
.values .values
.any( .any((room) => room.metadata?.dmUserId == userId);
(room) =>
room.metadata?.dmUserId == userId,
);
if (hasDm) { if (hasDm) {
context.startDirectMessage( context.startDirectMessage(userId, name: name);
userId, } else if (await context.showAdaptiveSheet<bool>(
name: name, (_) => StartDmSheet(userId: userId, name: name),
);
} else if (await context
.showAdaptiveSheet<bool>(
(_) => StartDmSheet(
userId: userId,
name: name,
),
) != ) !=
true) { true) {
return; return;
} }
if (context.mounted) { if (context.mounted) Navigator.of(context).pop();
Navigator.of(context).pop();
}
}, },
shape: .square, shape: .square,
style: .tonal, style: .tonal,
@ -378,47 +214,6 @@ final class const UserBottomSheet(
), ),
if (roomId != null && if (roomId != null &&
member.status == .knock &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .invite,
roomId: roomId!,
targetUser: userId,
),
),
))
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,
),
),
),
],
),
),
// Shown as a moderator would see it, while editing
if (editable ||
(roomId != null &&
ref.watch( ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
.membershipAction( .membershipAction(
@ -427,21 +222,15 @@ final class const UserBottomSheet(
targetUser: userId, targetUser: userId,
), ),
), ),
))) ))
Padding( Padding(
padding: .only(top: 4), padding: .only(top: 4),
child: Row( child: Row(
spacing: 8, spacing: 8,
children: [ children: [
if (editable || if (member.status == .join || member.status == .invite)
switch (member.status) {
.join || .invite || .knock => true,
_ => false,
})
M3EButton.icon( M3EButton.icon(
onPressed: editable onPressed: () => showMembershipSheet(.kick),
? null
: () => showMembershipSheet(.kick),
shape: .square, shape: .square,
icon: Icon(Icons.sports_martial_arts), icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"), label: Text("Kick"),
@ -452,9 +241,7 @@ final class const UserBottomSheet(
), ),
M3EButton.icon( M3EButton.icon(
onPressed: editable onPressed: () => showMembershipSheet(.ban),
? null
: () => showMembershipSheet(.ban),
shape: .square, shape: .square,
icon: Icon(Icons.gavel), icon: Icon(Icons.gavel),
label: Text("Ban"), label: Text("Ban"),
@ -470,8 +257,6 @@ final class const UserBottomSheet(
], ],
), ),
), ),
), );
AsyncLoading _ => Loading(),
};
} }
} }

View file

@ -12,7 +12,9 @@
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
#include <pasteboard/pasteboard_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 <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) app_links_linux_registrar = g_autoptr(FlPluginRegistrar) app_links_linux_registrar =
@ -33,7 +35,13 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) pasteboard_registrar = g_autoptr(FlPluginRegistrar) pasteboard_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
pasteboard_plugin_register_with_registrar(pasteboard_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 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);
} }

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