diff --git a/gomuks b/gomuks index 53cae0e..ba82b6e 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 +Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index fef1c16..8432468 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -8,7 +8,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/cupertino.dart"; import "package:intl/intl.dart"; -import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; @@ -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/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; -import "package:nexus/models/requests/search.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/set_account_data.dart"; @@ -107,50 +105,39 @@ class ClientController extends AsyncNotifier { return handle; } - /// Calls gomuks with [data] as JSON, returning its decoded response. Future callGomuksMethod( Map data, - void Function( - int handle, - GomuksOwnedBuffer data, - Pointer> callback, - ) - call, + FutureOr Function(int handle, GomuksBorrowedBuffer data) + callback, ) async { - final handle = await future; - final buffer = data.toGomuksOwnedBuffer(); - - final completer = Completer(); - late final NativeCallable callable; - callable = .listener((GomuksResponse response) { - callable.close(); - try { - final json = response.buf.toJsonAndFree(); - - if (response.command.cast().toDartString() == "error") { - throw GomuksException(json.toString()); - } - - completer.complete(json); - } catch (error, stackTrace) { - completer.completeError(error, stackTrace); - } - }); + final bufferPointer = data.toGomuksBufferPtr(); 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; - } + final handle = await future; - return completer.future; + final response = await Isolate.run( + () => callback(handle, bufferPointer.ref), + ); + + final json = response.buf.toJsonAndFree(); + + if (response.command.cast().toDartString() == "error") { + throw GomuksException(json.toString()); + } + + return json; + } finally { + calloc + ..free(bufferPointer.ref.base) + ..free(bufferPointer); + } } Future handlePush(Map data) async { - final response = await callGomuksMethod(data, GomuksHandlePushAsync); + final response = await callGomuksMethod( + data, + (handle, data) async => GomuksHandlePush(handle, data), + ); return response?["notification"] == null ? null @@ -160,12 +147,11 @@ class ClientController extends AsyncNotifier { Future _sendCommand( String command, [ Map data = const {}, - ]) => callGomuksMethod(data, (handle, data, callback) { + ]) => callGomuksMethod(data, (handle, data) { final commandPointer = command.toNativeUtf8().cast(); try { - GomuksSubmitCommandAsync(handle, commandPointer, data, callback); + return GomuksSubmitCommand(handle, commandPointer, data); } finally { - // gomuks copies the command name before returning calloc.free(commandPointer); } }); @@ -236,9 +222,6 @@ class ClientController extends AsyncNotifier { Future joinRoom(JoinRoomRequest request) async => (await _sendCommand("join_room", request.toJson()))["room_id"]; - Future knockRoom(JoinRoomRequest request) async => - (await _sendCommand("knock_room", request.toJson()))["room_id"]; - Future createRoom(CreateRoomRequest request) async => (await _sendCommand("create_room", request.toJson()))["room_id"]; @@ -290,12 +273,6 @@ class ClientController extends AsyncNotifier { Future paginateManual(PaginateManualRequest request) async => .fromJson(await _sendCommand("paginate_manual", request.toJson())); - Future searchLocal(SearchRequest request) async => - .fromJson(await _sendCommand("search_local", request.toJson())); - - Future searchServer(SearchRequest request) async => - .fromJson(await _sendCommand("search_server", request.toJson())); - Future getEventContext(GetEventContextRequest request) async => .fromJson(await _sendCommand("get_event_context", request.toJson())); @@ -333,11 +310,6 @@ class ClientController extends AsyncNotifier { Future logout() => _sendCommand("logout"); - Future setTyping(String roomId, Duration timeout) => _sendCommand( - "set_typing", - {"room_id": roomId, "timeout": timeout.inMilliseconds}, - ); - Future markRead(Room room) async { if (room.timeline.isEmpty || room.metadata == null) return; final eventRowId = room.timeline[room.timeline.keys.reduce(max)]; @@ -345,14 +317,9 @@ class ClientController extends AsyncNotifier { if (event == null) return; - // Private until settings have loaded, in case the user turned sending off - final sendPublic = - ref.read(SettingsController.provider).value?.readReceipts == - .showAndSend; - await _sendCommand("mark_read", { "room_id": room.metadata!.id, - "receipt_type": sendPublic ? "m.read" : "m.read.private", + "receipt_type": "m.read", "event_id": event.eventId, }); } @@ -371,34 +338,6 @@ class ClientController extends AsyncNotifier { Future exchangeToken(OAuthExchangeTokenRequest request) async => await _sendCommand("oauth_exchange_token", request.toJson()); - Future> getMutualRooms(String userId) async => IList( - ((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"] - as List?) - ?.cast(), - ); - - Future> getLocalAliases(String roomId) async => IList( - ((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] - as List?) - ?.cast(), - ); - - Future createAlias(String roomId, String alias) => - _sendCommand("create_alias", {"room_id": roomId, "alias": alias}); - - Future deleteAlias(String alias) => - _sendCommand("delete_alias", {"alias": alias}); - - /// The room [alias] points to, or null if it doesn't exist. - Future 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 getSpecVersions() async => .fromJson(await _sendCommand("get_versions")); diff --git a/lib/controllers/event_search.dart b/lib/controllers/event_search.dart deleted file mode 100644 index ea78567..0000000 --- a/lib/controllers/event_search.dart +++ /dev/null @@ -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 rowIds, String? nextBatch}); - -class EventSearchController(final EventSearchParams info) - extends AsyncNotifier { - // 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 build() async { - if (info.query.trim().isEmpty) { - return (rowIds: IList(), 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(), 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 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); -} diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index a97599b..9d742fa 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -15,7 +15,6 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; -import "package:nexus/controllers/typing.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:ffi/ffi.dart"; @@ -64,10 +63,6 @@ class GomuksListenerController extends AsyncNotifier { event.roomId: .new(events: .new({event.rowId: event})), }), ); - case "typing": - ref - .read(TypingController.provider.notifier) - .set(.fromJson(decodedMuksEvent)); case "events_decrypted": final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); final metadata = ref @@ -101,7 +96,6 @@ class GomuksListenerController extends AsyncNotifier { ref.invalidate(roomProvider); ref.invalidate(accountDataProvider); ref.invalidate(invitesProvider); - ref.invalidate(TypingController.provider); } ref @@ -159,7 +153,6 @@ class GomuksListenerController extends AsyncNotifier { if (paused) { GomuksPauseSync(handle); } else { - ref.invalidate(TypingController.provider); GomuksResumeSync(handle); } }, fireImmediately: true); diff --git a/lib/controllers/local_aliases.dart b/lib/controllers/local_aliases.dart deleted file mode 100644 index e80d4bb..0000000 --- a/lib/controllers/local_aliases.dart +++ /dev/null @@ -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> { - @override - Future> build() => - ref.read(ClientController.provider.notifier).getLocalAliases(roomId); - - Future add(String alias) async { - await ref - .read(ClientController.provider.notifier) - .createAlias(roomId, alias); - ref.invalidateSelf(); - } - - Future remove(String alias) async { - await ref.read(ClientController.provider.notifier).deleteAlias(alias); - ref.invalidateSelf(); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose, String>( - LocalAliasesController.new, - ); -} diff --git a/lib/controllers/members_grouped.dart b/lib/controllers/members_grouped.dart index a668896..f903ec4 100644 --- a/lib/controllers/members_grouped.dart +++ b/lib/controllers/members_grouped.dart @@ -1,27 +1,44 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_by_status.dart"; -import "package:nexus/controllers/room_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/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/event.dart"; class MembersGroupedController(final MembersByStatusConfig config) extends AsyncNotifier>>> { @override Future>>> build() async { - final powerLevels = ref.watch( - RoomPowerLevelsController.provider(config.roomId), + final room = ref.watch( + 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( MembersByStatusController.provider(config).future, ); return members .fold>>(.new(), (result, event) { - final groupKey = powerLevels.creators.contains(event.stateKey!) + final groupKey = roomCreators?.contains(event.stateKey!) == true ? null - : powerLevels.levelOf(event.stateKey!).toInt(); + : content.users[event.stateKey!] ?? content.usersDefault; return result.update( groupKey, diff --git a/lib/controllers/mutual_rooms.dart b/lib/controllers/mutual_rooms.dart deleted file mode 100644 index da4086a..0000000 --- a/lib/controllers/mutual_rooms.dart +++ /dev/null @@ -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> { - @override - Future> build() => - ref.read(ClientController.provider.notifier).getMutualRooms(userId); - - static final provider = AsyncNotifierProvider.family - .autoDispose, String>( - MutualRoomsController.new, - ); -} diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index e38ffae..4140b66 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,3 +1,4 @@ +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; @@ -8,6 +9,26 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); class NotificationsController(final NotificationsRequest request) extends AsyncNotifier> { + void storeEvents(Iterable events) => ref + .read(RoomsController.provider.notifier) + .update( + .new( + events + .groupListsBy((event) => event.roomId) + .map( + (roomId, roomEvents) => .new( + roomId, + .new( + events: IMap.fromIterable( + roomEvents, + keyMapper: (event) => event.rowId, + ), + ), + ), + ), + ), + ); + @override Future> build() async { final client = ref.read(ClientController.provider.notifier); @@ -22,9 +43,7 @@ class NotificationsController(final NotificationsRequest request) ), ); - ref - .read(RoomsController.provider.notifier) - .storeEvents(mentions.events.addAll(mentions.relatedEvents)); + storeEvents(mentions.events.addAll(mentions.relatedEvents)); return .new(mentions.events.map((event) => (event.rowId, event.roomId))); } @@ -57,11 +76,9 @@ class NotificationsController(final NotificationsRequest request) ), ); - ref - .read(RoomsController.provider.notifier) - .storeEvents( - newNotifications.events.addAll(newNotifications.relatedEvents), - ); + storeEvents( + newNotifications.events.addAll(newNotifications.relatedEvents), + ); return currentNotifications.addAll( newNotifications.events.map((event) => (event.rowId, event.roomId)), diff --git a/lib/controllers/power_level.dart b/lib/controllers/power_level.dart index 6fe9d68..7111e38 100644 --- a/lib/controllers/power_level.dart +++ b/lib/controllers/power_level.dart @@ -1,8 +1,10 @@ import "package:flutter_riverpod/flutter_riverpod.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/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; class PowerLevelController(final PowerLevelConfig config) extends Notifier { @@ -15,17 +17,31 @@ class PowerLevelController(final PowerLevelConfig config) ); } - final powerLevels = ref.watch( - RoomPowerLevelsController.provider(config.roomId), + final room = ref.watch( + 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( 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) { EventPowerLevelConfig(:final eventType) => @@ -36,12 +52,10 @@ class PowerLevelController(final PowerLevelConfig config) .invite => userLevel >= content.invite, .kick => - userLevel >= content.kick && - userLevel > powerLevels.levelOf(targetUser), + userLevel >= content.kick && userLevel > powerLevelOf(targetUser), .ban => - userLevel >= content.ban && - userLevel > powerLevels.levelOf(targetUser), + userLevel >= content.ban && userLevel > powerLevelOf(targetUser), .unban => userLevel >= content.ban, }, diff --git a/lib/controllers/power_levels_saving.dart b/lib/controllers/power_levels_saving.dart deleted file mode 100644 index d20d326..0000000 --- a/lib/controllers/power_levels_saving.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; - -class PowerLevelsSavingController(final String roomId) extends Notifier { - @override - bool build() => false; - - Future run(Future Function() action) async { - state = true; - try { - await action(); - } finally { - if (ref.mounted) state = false; - } - } - - static final provider = NotifierProvider.autoDispose - .family( - PowerLevelsSavingController.new, - ); -} diff --git a/lib/controllers/read_receipts.dart b/lib/controllers/read_receipts.dart deleted file mode 100644 index 15f76aa..0000000 --- a/lib/controllers/read_receipts.dart +++ /dev/null @@ -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>> { - @override - IMap> 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>, - ChatParams - >(ReadReceiptsController.new); -} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index b8b688d..c4a8dda 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -73,7 +73,16 @@ class RoomChatController(final ChatParams info) .addAll(context.after); ref .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( timeline: .new(events.map((element) => element.rowId)), hasMoreBackward: true, @@ -156,7 +165,6 @@ class RoomChatController(final ChatParams info) valueMapper: (event) => event, ), hasMore: response.hasMore, - receipts: response.receipts, timeline: IMap.fromIterable( response.events, keyMapper: (event) => event.timelineRowId, @@ -178,8 +186,17 @@ class RoomChatController(final ChatParams info) ref .read(RoomsController.provider.notifier) - .storeEvents( - paginationResponse.events.addAll(paginationResponse.relatedEvents), + .update( + .new({ + info.roomId: Room( + events: IMap.fromIterable( + paginationResponse.events.addAll( + paginationResponse.relatedEvents, + ), + keyMapper: (event) => event.rowId, + ), + ), + }), ); final eventRowIds = diff --git a/lib/controllers/room_creators.dart b/lib/controllers/room_creators.dart new file mode 100644 index 0000000..09acdd7 --- /dev/null +++ b/lib/controllers/room_creators.dart @@ -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> { + @override + IList 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 creators => creators.add(createEvent.sender), + _ => .new([createEvent.sender]), + }; + } + + static final provider = + NotifierProvider.family, Room>( + RoomCreatorsController.new, + ); +} diff --git a/lib/controllers/room_power_levels.dart b/lib/controllers/room_power_levels.dart deleted file mode 100644 index e33c436..0000000 --- a/lib/controllers/room_power_levels.dart +++ /dev/null @@ -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 { - @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.new, - ); -} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart deleted file mode 100644 index 74f0d9f..0000000 --- a/lib/controllers/room_settings_sections.dart +++ /dev/null @@ -1,817 +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>> { - @override - Future>> build() async { - final room = ref.watch( - RoomsController.provider.select((rooms) => rooms[roomId]), - ); - final client = ref.read(ClientController.provider.notifier); - final roomsController = ref.read(RoomsController.provider.notifier); - - // The room's own state, rather than metadata like a name made up from - // its members when it has none - Content? stateContent(EventType type) => - switch (room?.state[type.type]?[""]) { - final rowId? => room?.events[rowId]?.content, - null => null, - }; - final name = switch (stateContent(.name)) { - NameContent(:final name) => name, - _ => null, - }; - final topic = switch (stateContent(.topic)) { - TopicContent(:final topic) => topic, - _ => room?.metadata?.topic, - }; - final avatar = switch (stateContent(.avatar)) { - AvatarContent(:final url) => url, - _ => room?.metadata?.avatar, - }; - - bool canSet(EventType type) => ref.watch( - PowerLevelController.provider(.state(eventType: type, roomId: roomId)), - ); - - final canSetAvatar = canSet(.avatar); - final canSetName = canSet(.name); - final canSetTopic = canSet(.topic); - final canSetJoinRules = canSet(.joinRules); - final canSetHistoryVisibility = canSet(.historyVisibility); - final canSetAliases = canSet(.canonicalAlias); - final canSetPowerLevels = - canSet(.powerLevels) && - !ref.watch(PowerLevelsSavingController.provider(roomId)); - - final joinRules = switch (stateContent(.joinRules)) { - final JoinRulesContent content => content, - _ => JoinRulesContent(joinRule: .invite), - }; - final historyVisibility = switch (stateContent(.historyVisibility)) { - HistoryVisibilityContent(:final historyVisibility) => historyVisibility, - _ => HistoryVisibility.shared, - }; - final canonicalAlias = switch (stateContent(.canonicalAlias)) { - final CanonicalAliasContent content => content, - _ => CanonicalAliasContent(), - }; - final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]); - final localAliases = - ref.watch(LocalAliasesController.provider(roomId)).value ?? - const IList.empty(); - - final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([ - ?canonicalAlias.alias, - ]).toISet(); - - final roomVersion = switch (stateContent(.create)) { - CreateContent(:final roomVersion) => int.tryParse(roomVersion), - _ => null, - }; - bool versionAtLeast(int version) => - roomVersion == null || roomVersion >= version; - final joinRuleOptions = [ - JoinRule.public, - JoinRule.invite, - if (versionAtLeast(7)) JoinRule.knock, - if (versionAtLeast(8)) JoinRule.restricted, - if (versionAtLeast(10)) JoinRule.knockRestricted, - ]; - bool isRestricted(JoinRule rule) => - rule == .restricted || rule == .knockRestricted; - - final parentSpaces = ref - .watch( - SpaceEdgesController.provider.select( - (edges) => edges.entries - .where( - (entry) => entry.value.any((edge) => edge.childId == roomId), - ) - .map((entry) => entry.key) - .toIList(), - ), - ) - .addAll(room?.state["m.space.parent"]?.keys ?? const []) - .toISet(); - final allowedRooms = joinRules.allow - .map((condition) => condition.roomId) - .nonNulls - .toISet(); - - final roomPowerLevels = ref.watch( - RoomPowerLevelsController.provider(roomId), - ); - final powerLevels = roomPowerLevels.content; - final userId = ref.watch( - ClientStateController.provider.select((state) => state?.userId), - ); - final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); - // Encrypted rooms send messages as m.room.encrypted, so that's what limits them - final messageType = stateContent(.encryption) is EncryptionContent - ? EventType.encrypted - : EventType.message; - - Future 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 setAliases( - String? canonical, - ISet 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 setAllowedRooms(ISet 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 setPowerLevels(PowerLevelsContent content) => ref - .read(PowerLevelsSavingController.provider(roomId).notifier) - .run(() => setState(.powerLevels, content).onError(showError)); - - // Event types with their own level that don't have a setting of their own - final listedEvents = [ - messageType, - EventType.sticker, - EventType.reaction, - EventType.pinnedEvents, - ].map((type) => type.type).toISet(); - final otherEvents = powerLevels.events.entries - .where((entry) => !listedEvents.contains(entry.key)) - .toIList() - .sort((a, b) => a.key.compareTo(b.key)); - - // Users with their own level, the highest first - final users = powerLevels.users.entries.toIList().sort( - (a, b) => switch (b.value.compareTo(a.value)) { - 0 => a.key.compareTo(b.key), - final order => order, - }, - ); - MembershipContent? memberOf(String userId) => - switch (room?.state[EventType.membership.type]?[userId]) { - final rowId? => switch (room?.events[rowId]?.content) { - final MembershipContent content => content, - _ => null, - }, - null => null, - }; - - /// A tile opening a [sheet] to add entries, like event types or users, with their own power level. - Setting addSetting({ - required String title, - required String description, - required Widget Function(void Function(PowerLevelsContent content) save) - sheet, - }) => .new( - title: title, - description: description, - icon: Icons.add, - builder: (title, description, icon) => Builder( - builder: (context) => ListTile( - enabled: canSetPowerLevels, - leading: Icon(icon), - title: Text(title), - subtitle: Text(description), - onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)), - ), - ), - ); - - Setting powerLevelSetting({ - required String title, - required String description, - required IconData icon, - required int value, - required PowerLevelsContent Function(int level) update, - }) => .new( - title: title, - description: description, - icon: icon, - builder: (title, description, icon) => PowerLevelTile( - leading: Icon(icon), - title: title, - description: description, - value: value, - ownLevel: ownLevel, - onChanged: canSetPowerLevels - ? (level) => setPowerLevels(update(level)) - : null, - ), - ); - - /// A setting for the level needed to send [type], which otherwise needs [fallback]. - Setting eventSetting( - EventType type, { - required String title, - required String description, - required IconData icon, - required int fallback, - }) => powerLevelSetting( - title: title, - description: description, - icon: icon, - value: powerLevels.events[type.type] ?? fallback, - update: (level) => powerLevels.copyWith( - events: powerLevels.events.add(type.type, level), - ), - ); - - return .new({ - "Room": .new([ - .new( - title: "General", - icon: Icons.tune, - settings: .new([ - .new( - title: "Avatar", - description: "The image shown for the room.", - icon: Icons.image, - builder: (title, description, icon) => HookBuilder( - builder: (context) { - final saving = Saving.use(); - Future save(Future 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( - 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( - 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 - >(RoomSettingsSectionsController.new); -} diff --git a/lib/controllers/room_typing.dart b/lib/controllers/room_typing.dart deleted file mode 100644 index 887a8b2..0000000 --- a/lib/controllers/room_typing.dart +++ /dev/null @@ -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> { - @override - IList 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, String>( - RoomTypingController.new, - ); -} diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 5370bcf..7a5c181 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -1,10 +1,7 @@ -import "dart:async"; - import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; class RoomsController extends Notifier> { @@ -36,49 +33,9 @@ class RoomsController extends Notifier> { .new(), ); - static IMap> mergeReceipts( - IMap> existing, - IMap> 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 events) => update( - .new( - events - .groupListsBy((event) => event.roomId) - .map( - (roomId, roomEvents) => .new( - roomId, - Room( - // ignore: unused_result - I am using it you donkey - events: .fromIterable( - roomEvents, - keyMapper: (event) => event.rowId, - ), - ), - ), - ), - ), - ); - void update( IMap rooms, [ - ISet leftRooms = const .empty(), + ISet leftRooms = const ISet.empty(), ]) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; @@ -119,42 +76,21 @@ class RoomsController extends Notifier> { timeline: incoming.reset ? incoming.timeline : existing.timeline.addAll(incoming.timeline), - receipts: mergeReceipts(existing.receipts, incoming.receipts), + receipts: incoming.receipts.entries.fold( + existing.receipts, + (receiptAcc, event) => receiptAcc.add( + event.key, + (receiptAcc[event.key] ?? .new()).addAll(event.value), + ), + ), ) ?? - incoming.copyWith( - receipts: mergeReceipts(const .empty(), incoming.receipts), - ), + incoming, ); }); 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 awaitState( - String roomId, - String type, - String eventId, { - String stateKey = "", - }) { - bool arrived(IMap 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(); - 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.new, ); diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 665ecb1..1ca07a8 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -19,9 +19,7 @@ import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/room_order.dart"; -import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; -import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; @@ -33,30 +31,6 @@ class SettingsSectionsController Future>> build() async { final settings = await ref.watch(SettingsController.provider.future); - /// A tile for a [ShowAndSend] setting, saving the settings from [update]. - Widget showAndSendTile( - String title, - String description, - IconData icon, { - required ShowAndSend value, - required Settings Function(ShowAndSend value) update, - }) => DialogListTile( - icon: Icon(icon), - title: title, - subtitle: Text(description), - value: value, - options: ShowAndSend.values, - getName: (option) => switch (option) { - .showAndSend => "Show and send", - .show => "Only show", - .neither => "Neither", - }, - onChanged: (value) => ref - .read(SettingsController.provider.notifier) - .set(update(value)) - .onError(showError), - ); - return .new({ "General": .new([ .new( @@ -177,7 +151,7 @@ class SettingsSectionsController ), .new( 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, builder: (title, description, icon) => SwitchListTile( title: Text(title), @@ -192,51 +166,6 @@ class SettingsSectionsController : null, ), ), - .new( - title: "Hide Membership Events", - description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.", - icon: Icons.person_off, - builder: (title, description, icon) => SwitchListTile( - title: Text(title), - subtitle: Text(description), - secondary: Icon(icon), - value: settings.hideMembershipEvents, - onChanged: (value) => ref - .read(SettingsController.provider.notifier) - .set(settings.copyWith(hideMembershipEvents: value)) - .onError(showError), - ), - ), - ]), - ), - .new( - title: "Privacy", - icon: Icons.privacy_tip, - settings: .new([ - .new( - title: "Typing Indicators", - description: "Show when others are typing, and let others see when you're typing.", - icon: Icons.keyboard, - builder: (title, description, icon) => showAndSendTile( - title, - description, - icon, - value: settings.typingIndicators, - update: (value) => settings.copyWith(typingIndicators: value), - ), - ), - .new( - title: "Read Receipts", - description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.", - icon: Icons.done_all, - builder: (title, description, icon) => showAndSendTile( - title, - description, - icon, - value: settings.readReceipts, - update: (value) => settings.copyWith(readReceipts: value), - ), - ), ]), ), ]), diff --git a/lib/controllers/typing.dart b/lib/controllers/typing.dart deleted file mode 100644 index d1f3d28..0000000 --- a/lib/controllers/typing.dart +++ /dev/null @@ -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>> { - @override - IMap> build() => .new(); - - void set(Typing typing) => state = state.add(typing.roomId, typing.userIds); - - static final provider = - NotifierProvider>>( - TypingController.new, - ); -} diff --git a/lib/controllers/unified_push.dart b/lib/controllers/unified_push.dart index 40c4666..67ef8c2 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -88,8 +88,7 @@ class UnifiedPushController extends AsyncNotifier { if (notification == null || (!isInBackground && - (!Platform.isAndroid && - (WindowManager.instance.getCurrent()?.isFocused ?? true)) && + await windowManager.isFocused().onError((_, _) => true) && ref.read(SelectedRoomController.provider) == notification.roomId)) { if (isInBackground) exit(0); diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index e054d02..beaae85 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -8,12 +8,10 @@ extension BetterWhen on AsyncValue { required Widget Function(T value) data, Widget Function() loading = Loading.new, bool skipLoadingOnRefresh = false, - bool skipLoadingOnReload = false, }) => when( data: data, error: (error, stackTrace) => ErrorDialog(error, stackTrace), loading: loading, skipLoadingOnRefresh: skipLoadingOnRefresh, - skipLoadingOnReload: skipLoadingOnReload, ); } diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 57425be..10a273a 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -19,21 +19,19 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer { } extension JsonToGomuksBuffer on Map { - 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 toGomuksBufferPtr() { - final bytes = utf8.encode(json.encode(this)); + final jsonString = json.encode(this); + final bytes = utf8.encode(jsonString); final dataPtr = calloc(bytes.length); dataPtr.asTypedList(bytes.length).setAll(0, bytes); - return calloc() - ..ref.base = dataPtr - ..ref.length = bytes.length; + final ptr = calloc(); + + ptr.ref + ..base = dataPtr + ..length = bytes.length; + + return ptr; } } diff --git a/lib/helpers/extensions/is_shown_as_message.dart b/lib/helpers/extensions/is_shown_as_message.dart deleted file mode 100644 index a78810b..0000000 --- a/lib/helpers/extensions/is_shown_as_message.dart +++ /dev/null @@ -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, - }; -} diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart index df939c1..be5d6a5 100644 --- a/lib/helpers/extensions/join_room.dart +++ b/lib/helpers/extensions/join_room.dart @@ -40,29 +40,4 @@ extension JoinRoom on BuildContext { return roomId; } - - /// Knocks on a room, success and errors in snackbars. - /// Returns the room's ID, or null if knocking failed. - Future 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; - } } diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index a56417e..b1bfeff 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,8 +1,14 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(String userId, {String? roomId}) => - showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); + void showUserPopover( + MembershipContent member, + String userId, { + String? roomId, + }) => showAdaptivePopover( + (context) => UserBottomSheet(member, userId, roomId: roomId), + ); } diff --git a/lib/helpers/hooks/saving.dart b/lib/helpers/hooks/saving.dart deleted file mode 100644 index 3a0faef..0000000 --- a/lib/helpers/hooks/saving.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "dart:async"; - -import "package:flutter_hooks/flutter_hooks.dart"; - -final class Saving({ - required final bool active, - required final Future Function(FutureOr 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; - } - }, - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 5cd2129..94469cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -83,10 +83,14 @@ void main(List args) async { exit(0); } else { if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { - WindowManager.instance.getCurrent() - ?..titleBarStyle = .hidden - ..isWindowControlButtonsVisible = false - ..minimumSize = Size.square(300).toNative(); + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow( + WindowOptions( + titleBarStyle: TitleBarStyle.hidden, + windowButtonVisibility: false, + ), + ); + await windowManager.setMinimumSize(Size.square(500)); } runApp( @@ -107,21 +111,10 @@ class const App({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( - builder: (context, ref, child) { - // Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android - ThemeData forPlatform(ThemeData theme) => - ref.watch( - SettingsController.provider.select( - (settings) => settings.value?.linuxMobileMode ?? false, - ), - ) - ? theme.copyWith(platform: .android) - : theme; - - return MaterialApp( - navigatorKey: navigatorKey, - debugShowCheckedModeBanner: false, - theme: forPlatform( + builder: (context, ref, child) => MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: (ref .watch(SettingsController.provider) .maybeWhen( @@ -131,8 +124,7 @@ class const App({super.key}) extends StatelessWidget { ) ?? ThemeData.light().colorScheme) .theme, - ), - darkTheme: forPlatform( + darkTheme: (ref .watch(SettingsController.provider) .maybeWhen( @@ -142,16 +134,14 @@ class const App({super.key}) extends StatelessWidget { ) ?? ThemeData.dark().colorScheme) .theme, - ), - themeMode: ref - .watch(SettingsController.provider) - .maybeWhen( - data: (settings) => settings.theme, - orElse: () => ThemeMode.system, - ), - home: child, - ); - }, + themeMode: ref + .watch(SettingsController.provider) + .maybeWhen( + data: (settings) => settings.theme, + orElse: () => ThemeMode.system, + ), + home: child, + ), child: DefaultTextHeightBehavior( textHeightBehavior: .new(leadingDistribution: .even), child: Scaffold( diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 0ab5990..955d8d1 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -12,7 +12,7 @@ class const AccountData({ const InvitePermissionConfig(), @JsonKey(name: AccountData.directKey) - final IMap> directMessages = const .empty(), + final IMap> directMessages = const IMap.empty(), @JsonKey(name: AccountData.recentEmojiKey) final RecentEmojiData recentEmoji = const RecentEmojiData(), @@ -30,7 +30,7 @@ class const AccountData({ @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const RecentEmojiData({ - final IList recentEmoji = const .empty(), + final IList recentEmoji = const IList.empty(), }) with _$RecentEmojiData { Map toJson() => _$RecentEmojiDataToJson(this); diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 6fb2829..1a35d92 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -9,7 +9,7 @@ sealed class CanonicalAliasContent extends Content with _$CanonicalAliasContent { CanonicalAliasContent._(); factory CanonicalAliasContent({ - @JsonKey(includeIfNull: false) String? alias, + String? alias, @Default(ISet.empty()) ISet altAliases, }) = _CanonicalAliasContent; diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 948a762..3f1c17b 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent { JoinRulesContent._(); factory JoinRulesContent({ required JoinRule joinRule, - @Default(ISet.empty()) ISet allow, + @Default(IList.empty()) IList allow, }) = _JoinRulesContent; factory JoinRulesContent.fromJson(Map json) => @@ -21,8 +21,6 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent { @JsonSerializable() class const AllowCondition({ final String? roomId, - - @JsonKey(unknownEnumValue: AllowConditionType.unknown) required final AllowConditionType type, }) with _$AllowCondition { Map toJson() => _$AllowConditionToJson(this); @@ -34,5 +32,4 @@ class const AllowCondition({ enum AllowConditionType { @JsonValue("m.room_membership") membership, - unknown, } diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index afa0e23..5ae6561 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { factory PowerLevelsContent({ @Default(IMap.empty()) IMap events, @Default(IMap.empty()) IMap users, - @JsonKey(includeIfNull: false) Notifications? notifications, + Notifications? notifications, @Default(50) int ban, @Default(0) int eventsDefault, @Default(0) int invite, @@ -28,7 +28,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { @JsonSerializable() class const Notifications({ final int room = 50, - final IMap other = const .empty(), + final IMap other = const IMap.empty(), }) with _$Notifications { Map toJson() => _$NotificationsToJson(this); diff --git a/lib/models/event.dart b/lib/models/event.dart index 93352ca..18ad11b 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -18,7 +18,7 @@ class const Event({ required final String sender, @JsonKey(readValue: Event.typeJsonFromJson) required final String type, @EpochDateTimeConverter() required final DateTime timestamp, - final IMap unsigned = const .empty(), + final IMap unsigned = const IMap.empty(), final LocalContent? localContent, final String? transactionId, final String? redactedBy, @@ -27,7 +27,7 @@ class const Event({ final String? replyTo, final String? decryptionError, final String? sendError, - final IMap reactions = const .empty(), + final IMap reactions = const IMap.empty(), @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, final UnreadType? unreadType, final Profile? pmp, diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart index e4ae517..6103c7d 100644 --- a/lib/models/event_context.dart +++ b/lib/models/event_context.dart @@ -13,7 +13,7 @@ class const EventContext({ required final IList after, required final String start, required final String end, - final IList relatedEvents = const .empty(), + final IList relatedEvents = const IList.empty(), }) with _$EventContext { Map toJson() => _$EventContextToJson(this); diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart index cc09d1c..266fb01 100644 --- a/lib/models/info/image.dart +++ b/lib/models/info/image.dart @@ -6,8 +6,8 @@ part "image.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const ImageInfo({ - @JsonKey(name: "h") final int? height, - @JsonKey(name: "w") final int? width, + @JsonKey(name: "h") final double? height, + @JsonKey(name: "w") final double? width, @JsonKey(name: "mimetype") final String? mimeType, @JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash, final int? size, diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index b7c6bf8..dc6eede 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,7 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/models/read_receipt.dart"; part "paginate.freezed.dart"; part "paginate.g.dart"; @@ -11,9 +10,6 @@ part "paginate.g.dart"; class const Paginate({ required final IList events, required final IList relatedEvents, - - /// Read receipts for the events, by event ID. - final IMap> receipts = const .empty(), required final bool hasMore, }) with _$Paginate { Map toJson() => _$PaginateToJson(this); diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart index fbd3fa7..9f45c1f 100644 --- a/lib/models/paginate_manual.dart +++ b/lib/models/paginate_manual.dart @@ -8,8 +8,8 @@ part "paginate_manual.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const PaginateManual({ - final IList events = const .empty(), - final IList relatedEvents = const .empty(), + required final IList events, + final IList relatedEvents = const IList.empty(), required final String? nextBatch, }) with _$PaginateManual { Map toJson() => _$PaginateManualToJson(this); diff --git a/lib/models/profile_response.dart b/lib/models/profile_response.dart index 3896e32..57f76c5 100644 --- a/lib/models/profile_response.dart +++ b/lib/models/profile_response.dart @@ -40,7 +40,7 @@ class const Profile({ final String? timezone, @JsonKey(readValue: Profile.readPronouns, name: "m.pronouns") - final IList pronouns = const .empty(), + final IList pronouns = const IList.empty(), }) with _$Profile { Map toJson() => _$ProfileToJson(this); diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart index 4d62635..ab4edd8 100644 --- a/lib/models/read_receipt.dart +++ b/lib/models/read_receipt.dart @@ -13,11 +13,6 @@ class const ReadReceipt({ required final String eventId, @EpochDateTimeConverter() required final DateTime timestamp, }) with _$ReadReceipt { - String get thread => switch (threadId) { - final threadId? when threadId.isNotEmpty => threadId, - _ => "main", - }; - Map toJson() => _$ReadReceiptToJson(this); factory ReadReceipt.fromJson(Map json) => diff --git a/lib/models/requests/create_room.dart b/lib/models/requests/create_room.dart index 262ee73..d2fbcc9 100644 --- a/lib/models/requests/create_room.dart +++ b/lib/models/requests/create_room.dart @@ -13,10 +13,10 @@ class const CreateRoomRequest({ final String? roomAliasName, final String? name, final String? topic, - final IList invite = const .empty(), + final IList invite = const IList.empty(), @JsonKey(name: "uk.timedout.msc4491.invite_reason") final String? inviteReason, - final IList initialState = const .empty(), + final IList initialState = const IList.empty(), final RoomPreset? preset, final bool isDirect = false, final IMap? creationContent, diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart index b8319cd..3cdbc88 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -8,8 +8,7 @@ part "join_room.g.dart"; @JsonSerializable() class const JoinRoomRequest({ required final String roomIdOrAlias, - final IList via = const .empty(), - final String? reason, + final IList via = const IList.empty(), /// Set when accepting an invite, so gomuks can mark DMs as such. final bool fromInvite = false, diff --git a/lib/models/requests/search.dart b/lib/models/requests/search.dart deleted file mode 100644 index 8a19d3e..0000000 --- a/lib/models/requests/search.dart +++ /dev/null @@ -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 roomIds = const .empty(), - final int limit = 20, - final bool sortByTime = true, - - final String? nextBatch, -}) with _$SearchRequest { - Map toJson() => _$SearchRequestToJson(this); - - factory SearchRequest.fromJson(Map json) => - _$SearchRequestFromJson(json); -} diff --git a/lib/models/requests/send_message.dart b/lib/models/requests/send_message.dart index 10a9330..9907439 100644 --- a/lib/models/requests/send_message.dart +++ b/lib/models/requests/send_message.dart @@ -25,7 +25,7 @@ class const SendMessageRequest({ @JsonSerializable() class const Mentions({ final bool room = false, - final IList userIds = const .empty(), + final IList userIds = const IList.empty(), }) with _$Mentions { Map toJson() => _$MentionsToJson(this); diff --git a/lib/models/room.dart b/lib/models/room.dart index f02c6ac..24b0c5e 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -14,15 +14,15 @@ class const Room({ /// [timeline] is an IMap of timelineRowId to eventRowId @JsonKey(fromJson: Room.timelineTupleJsonToIMap) - final IMap timeline = const .empty(), + final IMap timeline = const IMap.empty(), /// [clientSticky] is an ISet of eventRowId @JsonKey(includeFromJson: false, includeToJson: false) - final ISet clientSticky = const .empty(), + final ISet clientSticky = const ISet.empty(), /// [events] is an IMap of eventRowId to event @JsonKey(fromJson: Room.eventsJsonToIMap) - final IMap events = const .empty(), + final IMap events = const IMap.empty(), final bool reset = false, @JsonKey(includeFromJson: false, includeToJson: false) @@ -34,9 +34,9 @@ class const Room({ @JsonKey(includeFromJson: false, includeToJson: false) final bool hasFetchedMembers = false, - final IMap> state = const .empty(), + final IMap> state = const IMap.empty(), - final IMap> receipts = const .empty(), + final IMap> receipts = const IMap.empty(), final bool dismissNotifications = false, final bool hasMore = true, diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 7621650..822fcda 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -1,5 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; import "package:nexus/models/lazy_load_summary.dart"; @@ -16,8 +15,6 @@ class const RoomMetadata({ final String? topic, final String? canonicalAlias, final LazyLoadSummary? lazyLoadSummary, - - final EncryptionContent? encryptionEvent, required final bool hasMemberList, @JsonKey(name: "preview_event_rowid") required final int previewEventRowID, @EpochDateTimeConverter() required final DateTime sortingTimestamp, diff --git a/lib/models/room_power_levels.dart b/lib/models/room_power_levels.dart deleted file mode 100644 index 028d161..0000000 --- a/lib/models/room_power_levels.dart +++ /dev/null @@ -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 creators = const .empty(), -}) { - double levelOf(String userId) => creators.contains(userId) - ? double.infinity - : (content.users[userId] ?? content.usersDefault).toDouble(); -} diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart index 05eaf43..6d22f6c 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -1,4 +1,3 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/membership.dart"; @@ -15,9 +14,6 @@ class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, final JoinRule? joinRule, - - /// Rooms whose members may join without an invite, for restricted rooms. - final IList allowedRoomIds = const .empty(), final String? name, final Uri? avatarUrl, final String? canonicalAlias, @@ -45,14 +41,6 @@ class const RoomSummary({ /// Whether this is an invite to a direct message. @JsonKey(includeFromJson: false) final bool isDirect = false, }) with _$RoomSummary { - /// Whether the user has to knock rather than join, given the rooms they've - /// joined. Restricted knock rooms can be joined directly from an allowed room. - bool mustKnock(ISet joinedRooms) => switch (joinRule) { - .knock => true, - .knockRestricted => !allowedRoomIds.any(joinedRooms.contains), - _ => false, - }; - /// A summary of a room the user is in, from its synced metadata. factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new( roomId: metadata.id, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7ea61d9..f9198ed 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,7 +1,6 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room_order.dart"; -import "package:nexus/models/show_and_send.dart"; part "settings.freezed.dart"; part "settings.g.dart"; @@ -15,13 +14,7 @@ class const Settings({ final bool showAllInHome = false, final bool showMinimizeButton = false, final bool swipeToReply = false, - final bool hideMembershipEvents = false, final RoomOrder roomOrder = .unreadFirst, - final ShowAndSend typingIndicators = .showAndSend, - - /// When not sending, read receipts are still sent privately, so that the - /// user's other devices know what has been read. - final ShowAndSend readReceipts = .showAndSend, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/models/show_and_send.dart b/lib/models/show_and_send.dart deleted file mode 100644 index f9663cc..0000000 --- a/lib/models/show_and_send.dart +++ /dev/null @@ -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 } diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart index 4639956..a26043b 100644 --- a/lib/models/sync_data.dart +++ b/lib/models/sync_data.dart @@ -11,10 +11,10 @@ part "sync_data.g.dart"; @JsonSerializable(createToJson: false) class const SyncData({ final bool clearState = false, - final IMap> accountData = const .empty(), - final IMap rooms = const .empty(), - final ISet leftRooms = const .empty(), - final IList invitedRooms = const .empty(), + final IMap> accountData = const IMap.empty(), + final IMap rooms = const IMap.empty(), + final ISet leftRooms = const ISet.empty(), + final IList invitedRooms = const IList.empty(), final IMap>? spaceEdges, final IList? topLevelSpaces, }) with _$SyncData { diff --git a/lib/models/typing.dart b/lib/models/typing.dart deleted file mode 100644 index bd0198c..0000000 --- a/lib/models/typing.dart +++ /dev/null @@ -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 userIds = const .empty(), -}) with _$Typing { - factory Typing.fromJson(Map json) => _$TypingFromJson(json); -} diff --git a/lib/widgets/appbar.dart b/lib/widgets/appbar.dart index 123ae79..226da68 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -23,26 +23,23 @@ final class const Appbar({ @override Widget build(BuildContext context, WidgetRef ref) { - final isDesktop = !(Platform.isAndroid || Platform.isIOS); + Future maximize() async { + final isMaximized = await windowManager.isMaximized(); - void maximize() { - final window = WindowManager.instance.getCurrent(); - if (window == null) return; - - if (window.isMaximized) { - return window.unmaximize(); + if (isMaximized) { + return windowManager.unmaximize(); } - return window.maximize(); + return windowManager.maximize(); } return GestureDetector( onPanStart: ref .watch(SettingsController.provider) .whenOrNull( - data: (settings) => !isDesktop || settings.linuxMobileMode + data: (settings) => settings.linuxMobileMode ? null - : (_) => WindowManager.instance.getCurrent()?.startDragging(), + : (_) => windowManager.startDragging(), ), child: AppBar( leading: leading == null ? null : InkWell(onTap: onTap, child: leading), @@ -53,12 +50,10 @@ final class const Appbar({ onTap: onTap, child: IgnorePointer(child: title), ), - flexibleSpace: GestureDetector( - onDoubleTap: isDesktop ? maximize : null, - ), + flexibleSpace: GestureDetector(onDoubleTap: maximize), actions: [ ...actions, - if (windowControls && isDesktop) ...[ + if (windowControls && !(Platform.isAndroid || Platform.isIOS)) ...[ if (ref .watch(SettingsController.provider) .whenOrNull( @@ -67,8 +62,7 @@ final class const Appbar({ false) IconButton( tooltip: "Minimize window", - onPressed: () => - WindowManager.instance.getCurrent()?.minimize(), + onPressed: windowManager.minimize, icon: const Icon(Icons.minimize), ), if (!Platform.isLinux) diff --git a/lib/widgets/avatar_ring.dart b/lib/widgets/avatar_ring.dart deleted file mode 100644 index f410978..0000000 --- a/lib/widgets/avatar_ring.dart +++ /dev/null @@ -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), - ), - ], - ), - ), - ), - ), - ); -} diff --git a/lib/widgets/bouncing_dots.dart b/lib/widgets/bouncing_dots.dart deleted file mode 100644 index 37f655c..0000000 --- a/lib/widgets/bouncing_dots.dart +++ /dev/null @@ -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(), - ); - } -} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 2648347..9b596c6 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -11,8 +11,6 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/attachment.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/image_picker.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; @@ -96,44 +94,6 @@ class const Composer( return null; }, [relationType, relatedEvent == null]); - final sinceTypingSent = useMemoized(Stopwatch.new); - - useEffect(() { - final client = ref.read(ClientController.provider.notifier); - - void stopTyping() { - if (!sinceTypingSent.isRunning) return; - sinceTypingSent - ..stop() - ..reset(); - client.setTyping(roomId, .zero).ignore(); - } - - void onChanged() { - final shouldSend = - ref.read(SettingsController.provider).value?.typingIndicators == - .showAndSend; - - if (!shouldSend || controller.text.isEmpty) return stopTyping(); - - if (sinceTypingSent.isRunning && - sinceTypingSent.elapsed < const .new(seconds: 5)) { - return; - } - - sinceTypingSent - ..reset() - ..start(); - client.setTyping(roomId, const .new(seconds: 10)).ignore(); - } - - controller.addListener(onChanged); - return () { - controller.removeListener(onChanged); - stopTyping(); - }; - }, [roomId]); - final attachment = ref.watch(AttachmentController.provider(roomId)); Future attach(Future pick) async { diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart deleted file mode 100644 index 2a30c4b..0000000 --- a/lib/widgets/composer/typing_indicator.dart +++ /dev/null @@ -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, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 6ae2221..94aec91 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key}) ? SizedBox.shrink() : InkWell( onTap: () async { - if (data is MembershipContent) { - context.showUserPopover(mention, roomId: roomId); + if (data case MembershipContent membership) { + context.showUserPopover(membership, mention, roomId: roomId); } else if (data case RoomSummary summary) { final eventRowId = linkInfo?.eventId == null ? null diff --git a/lib/widgets/knock_sheet.dart b/lib/widgets/knock_sheet.dart deleted file mode 100644 index 9c6bdcb..0000000 --- a/lib/widgets/knock_sheet.dart +++ /dev/null @@ -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 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, - ), - ], - ); - } -} diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 0279eca..50b612a 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -12,17 +12,18 @@ class const MessageAvatar( super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => - switch (ref.watch(AuthorController.provider(event))) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTap: () => - context.showUserPopover(event.sender, roomId: event.roomId), - child: AvatarOrHash( - value.avatarUrl, - value.displayName ?? event.sender.localpart, - dimension: height, - ), - ), - _ => AvatarOrHash(null, event.sender.localpart, dimension: height), - }; + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( + AuthorController.provider(event), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(value, event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + dimension: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, dimension: height), + }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index e8e4791..593e755 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -21,8 +21,12 @@ class const MessageDisplayname( AsyncData(:final MembershipContent? value) || AsyncLoading(:final value) || AsyncError(:final value) => InkWell( - onTap: clickable - ? () => context.showUserPopover(event.sender, roomId: event.roomId) + onTap: clickable && value != null + ? () => context.showUserPopover( + value, + event.sender, + roomId: event.roomId, + ) : null, child: switch ([ Text( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 731270d..85e9fcc 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -25,7 +25,6 @@ class const MemberList(final String roomId, {super.key}) final options = { "Joined": .join, "Invited": .invite, - "Knocked": .knock, "Banned": .ban, }; final status = options.values.toIList()[statusIndex.value]; @@ -37,7 +36,7 @@ class const MemberList(final String roomId, {super.key}) body: Column( children: [ Padding( - padding: .all(8), + padding: .symmetric(vertical: 8), child: M3EToggleButtonGroup( selectedIndex: statusIndex.value, onSelectedIndexChanged: (index) => @@ -105,13 +104,15 @@ class const MemberList(final String roomId, {super.key}) itemBuilder: (context, index) => switch (members[index].content) { MembershipContent( - :final avatarUrl, - :final displayName, - ) => + :final avatarUrl, + :final displayName, + ) && + final content => Builder( builder: (context) => ListTile( onTap: () => context.showUserPopover( + content, members[index].stateKey!, roomId: roomId, ), diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 969dcd3..42c08d6 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -26,7 +26,7 @@ final class const MessageImage( aspectRatio: (info?.width ?? 1) / (info?.height ?? 1), child: Image( image: MxcImage(ref, request), - width: info?.width?.toDouble(), + width: info?.width, fit: BoxFit.fitWidth, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null @@ -40,7 +40,7 @@ final class const MessageImage( child: BlurHash(hash: blurHash), ) : SizedBox( - width: info!.width!.toDouble(), + width: info!.width, child: BlurHash(hash: blurHash), ), _ => Loading(), diff --git a/lib/widgets/mutual_rooms_sheet.dart b/lib/widgets/mutual_rooms_sheet.dart deleted file mode 100644 index 68b90e5..0000000 --- a/lib/widgets/mutual_rooms_sheet.dart +++ /dev/null @@ -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(), - ); - }, - ), - ], - ); -} diff --git a/lib/widgets/pages/settings.dart b/lib/widgets/pages/settings.dart index c205d3e..6bc298c 100644 --- a/lib/widgets/pages/settings.dart +++ b/lib/widgets/pages/settings.dart @@ -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: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/helpers/extensions/better_when.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 { @override - Widget build(BuildContext context, WidgetRef ref) => SettingsLayout( - title: "Settings", - sectionsProvider: SettingsSectionsController.provider, - actions: [ - IconButton( - onPressed: () => context.showAboutSheet(ref), - icon: Icon(Icons.info_outline), - ), - ], + Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( + builder: (_, constraints) => HookBuilder( + builder: (context) { + final categoriesArePages = constraints.maxWidth < 550; + + final selected = useState(0); + + final highlightedMatch = useState<(int, int)?>(null); + final listController = useRef(ListController()); + final scrollController = useScrollController(); + + final searchBar = SearchAnchor.bar( + barHintText: "Search...", + isFullScreen: categoriesArePages, + suggestionsBuilder: (suggestionsContext, controller) async { + final categories = await ref.read( + SettingsSectionsController.provider.future, + ); + final query = controller.text.toLowerCase(); + + final matches = categories.values.flattenedToList + .asMap() + .entries + .expand( + (categoryEntry) => categoryEntry.value.settings + .asMap() + .entries + .where( + (settingEntry) => + settingEntry.value.title.toLowerCase().contains( + query, + ) || + settingEntry.value.description + .toLowerCase() + .contains(query), + ) + .map( + (settingEntry) => ( + (categoryEntry.key, settingEntry.key), + settingEntry.value, + ), + ), + ) + .toIList(); + + return matches.map( + (match) => ListTile( + onTap: () async { + if (context.mounted) Navigator.of(suggestionsContext).pop(); + controller.text = ""; + + if (categoriesArePages) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => SettingsCategoryPage( + match.$1.$1, + initialHighlight: match.$1.$2, + ), + ), + ); + } else { + selected.value = match.$1.$1; + listController.value.animateToItem( + index: match.$1.$2, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => Durations.extralong1, + curve: (_) => Curves.easeInOut, + ); + highlightedMatch.value = match.$1; + await Future.delayed(Durations.extralong4, () { + if (highlightedMatch.value == match.$1) { + highlightedMatch.value = null; + } + }); + } + }, + leading: Icon(match.$2.icon), + title: Text(match.$2.title), + subtitle: Text(match.$2.description), + ), + ); + }, + ); + + final settingsContent = Scaffold( + appBar: AppBar( + title: Text("Settings"), + actionsPadding: .symmetric(horizontal: 12), + actions: [ + IconButton( + onPressed: () => context.showAboutSheet(ref), + icon: Icon(Icons.info_outline), + ), + ], + ), + body: ref + .watch(SettingsSectionsController.provider) + .betterWhen( + data: (sections) => categoriesArePages + ? CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(12).copyWith(bottom: 8), + child: searchBar, + ), + ), + ...sections + .mapTo( + (section, categories) => [ + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, + ).copyWith(bottom: 4), + child: DividerText(section), + ), + ), + SliverM3ECardList( + padding: .symmetric( + horizontal: 4, + vertical: 8, + ), + margin: .symmetric(horizontal: 12), + color: Theme.of(context) + .colorScheme + .primaryContainer, + itemCount: categories.length, + onTap: (index) => Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => + SettingsCategoryPage( + sections + .values + .flattenedToList + .indexOf( + categories[index], + ), + ), + ), + ), + itemBuilder: (context, index) => ListTile( + leading: Icon(categories[index].icon), + title: Text(categories[index].title), + ), + ), + ], + ) + .flattened, + ], + ) + : Row( + children: [ + MaterialUiCompatibilityBridge( + child: NavigationRailM3E( + type: .alwaysExpand, + trailing: searchBar, + scrollable: true, + sections: sections + .mapTo( + ( + categoryGroup, + categories, + ) => NavigationRailM3ESection( + header: DividerText(categoryGroup), + destinations: categories + .map( + (category) => + NavigationRailM3EDestination( + icon: Icon(category.icon), + label: category.title, + ), + ) + .toList(), + ), + ) + .toList(), + selectedIndex: selected.value, + onDestinationSelected: (value) => + selected.value = value, + ), + ), + VerticalDivider(), + Expanded( + child: SuperListView( + listController: listController.value, + controller: scrollController, + padding: .symmetric(vertical: 12), + children: sections + .values + .flattenedToList[selected.value] + .settings + .mapIndexed( + (index, setting) => Padding( + padding: .only(bottom: 4), + child: HighlightWrapper( + setting.builder( + setting.title, + setting.description, + setting.icon, + ), + isHighlighted: + highlightedMatch.value == + (selected.value, index), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ); + + return constraints.maxWidth < 650 + ? settingsContent + : Dialog( + constraints: .loose(Size(900, 600)), + child: ClipRRect( + borderRadius: BorderRadiusGeometry.circular(12), + child: settingsContent, + ), + ); + }, + ), ); } diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart index 69513b8..2398f7f 100644 --- a/lib/widgets/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -4,19 +4,13 @@ import "package:collection/collection.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:hooks_riverpod/misc.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:nexus/models/settings_category.dart"; +import "package:nexus/controllers/settings_sections.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const SettingsCategoryPage( final int index, { - required final ProviderListenable< - AsyncValue>> - > - sectionsProvider, final int? initialHighlight, super.key, }) extends HookConsumerWidget { @@ -51,9 +45,8 @@ class const SettingsCategoryPage( }, []); return ref - .watch(sectionsProvider) + .watch(SettingsSectionsController.provider) .betterWhen( - skipLoadingOnReload: true, data: (sections) => Scaffold( appBar: AppBar( title: Text(sections.values.flattenedToList[index].title), diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index 3e254b0..598687c 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -28,7 +28,6 @@ class const AudioPlayer( final audio = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); - if (!context.mounted) return; await player.open(Media(audio.path), play: false); if (context.mounted) loaded.value = true; }); diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index ef56024..557e7c1 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -30,7 +30,6 @@ class const VideoPlayer( final video = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); - if (!context.mounted) return; await player.open(Media(video.path), play: false); if (context.mounted) loaded.value = true; }); diff --git a/lib/widgets/read_receipts_row.dart b/lib/widgets/read_receipts_row.dart deleted file mode 100644 index e3f206f..0000000 --- a/lib/widgets/read_receipts_row.dart +++ /dev/null @@ -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, - ), - ), - ), - ), - ], - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/widgets/read_receipts_sheet.dart b/lib/widgets/read_receipts_sheet.dart deleted file mode 100644 index aabdfb3..0000000 --- a/lib/widgets/read_receipts_sheet.dart +++ /dev/null @@ -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(), - ); -} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 47750fa..3ec3d4a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -6,27 +6,27 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/settings.dart"; -import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; -import "package:nexus/widgets/read_receipts_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/swipe_to_reply.dart"; @@ -43,10 +43,6 @@ class const EventRenderer( /// If set, events can be swiped, calling this final VoidCallback? onSwipe, - - /// If set, shows who in this chat has read up to this event, e.g. in the - /// timeline. - final ChatParams? readReceiptsChat, super.key, }) extends HookConsumerWidget { @override @@ -82,24 +78,10 @@ class const EventRenderer( if (event == null) return SizedBox.shrink(); - final hideMembershipEvents = (ref - .watch(SettingsController.provider) - .maybeWhen( - orElse: () => false, - data: (settings) => settings.hideMembershipEvents, - )); - - // Knocks and their outcomes stay visible, as moderators need to act on them - if (hideMembershipEvents && - event.content is MembershipContent && - ![event.content, event.previousContent].any( - (content) => content is MembershipContent && content.status == .knock, - )) { - return SizedBox.shrink(); - } - final isGrouped = - (previousEvent?.isShownAsMessage ?? false) && + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; @@ -112,26 +94,7 @@ class const EventRenderer( TextSpan(text: text), ]); - final child = event.isShownAsMessage - ? MessageRenderer( - event, - onTapReply: jumpToEvent == null - ? null - : () async { - final replyEvent = await ref.read( - EventController.provider( - .new(roomId: roomId, eventId: event.replyTo!), - ).future, - ); - if (replyEvent != null) { - await jumpToEvent!(replyEvent.rowId); - } - }, - isGrouped: isGrouped, - maxLines: maxLines, - textOnly: textOnly, - ) - : event.redactedBy != null || event.relationType == "m.replace" + final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { Content(:final parseError?, :final stackTrace) => Row( @@ -143,6 +106,27 @@ class const EventRenderer( ], ), + MessageContent() || + EncryptedContent() || + StickerContent() => MessageRenderer( + event, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyEvent = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyEvent != null) { + await jumpToEvent!(replyEvent.rowId); + } + }, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ), + MembershipContent content => switch (event.previousContent) { MembershipContent(:final status) => status == content.status @@ -284,10 +268,6 @@ class const EventRenderer( ...[ if (child is! MessageRenderer) ReactionRow(event), - // Outside the message itself, so it doesn't move when swiped - if (readReceiptsChat case final chat? when event.isShownAsMessage) - ReadReceiptsRow(event.eventId, chat: chat), - if (event.sendError != null && event.sendError != "not sent") Padding( padding: .only(bottom: 4), diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index b1811f0..c57200c 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -19,11 +19,6 @@ class const MembershipRenderer( "Make sure to only pass membership events to MembershipRenderer", ); - final previous = switch (event.previousContent) { - MembershipContent(:final status) => status, - _ => null, - }; - return switch (event.content) { MembershipContent content => GenericEventRenderer( Icons.people, @@ -33,6 +28,7 @@ class const MembershipRenderer( alignment: .middle, child: InkWell( onTap: () => context.showUserPopover( + content, event.stateKey!, roomId: event.roomId, ), @@ -47,10 +43,9 @@ class const MembershipRenderer( TextSpan( text: "${switch (content.status) { - .invite => previous == .knock ? "was accepted into" : "was invited to", + .invite => "was invited to", .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" : (previous == .ban ? "was unbanned from" : "was kicked from"), + .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), .ban => "was banned from", .knock => "asked to join", }} the room${event.sender == event.stateKey ? "" : " by"}", diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 248661e..e284bc5 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -16,7 +16,6 @@ final class const RoomAppbar({ required final void Function() onOpenDrawer, final void Function(BuildContext context)? onOpenMemberList, final void Function()? onOpenPinnedMessagesList, - final void Function()? onOpenSearch, super.key, }) extends ConsumerWidget implements PreferredSizeWidget { @override @@ -62,11 +61,6 @@ final class const RoomAppbar({ room == null ? [] : [ - IconButton( - onPressed: onOpenSearch?.call, - icon: Icon(Icons.search), - tooltip: "Search this room", - ), IconButton( onPressed: onOpenPinnedMessagesList?.call, icon: Icon(Icons.push_pin), diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index c949344..8a4c071 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -1,6 +1,5 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; -import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/event.dart"; @@ -8,15 +7,12 @@ import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ - required final ChatParams chat, + required final String roomId, required final ChatScroll scroll, required final JumpToEvent jumpToEvent, required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, - - /// The typing indicator's height, or zero while it's hidden. - required final double typingIndicatorHeight, final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { @@ -28,11 +24,10 @@ class const ChatTimeline({ EventRenderer( eventRowId, previousEventRowId: previousEventRowId, - roomId: chat.roomId, + roomId: roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), - readReceiptsChat: chat, ), key: key, isHighlighted: highlightedEvent == eventRowId, @@ -45,22 +40,7 @@ class const ChatTimeline({ keyboardDismissBehavior: .onDrag, controller: scroll.scrollController, slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - // Makes room for the typing indicator, with a small gap above it, - // so it doesn't cover messages - AnimatedContainer( - duration: Durations.medium2, - curve: Curves.easeInOutCubicEmphasized, - height: typingIndicatorHeight == 0 - ? 0 - : typingIndicatorHeight + 4, - ), - SizedBox(height: composerHeight), - ], - ), - ), + SliverToBoxAdapter(child: SizedBox(height: composerHeight)), SuperSliverList.builder( itemCount: scroll.liveRows.length, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index e7fd6e0..155e90e 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -15,13 +15,11 @@ import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; -import "package:nexus/widgets/composer/typing_indicator.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; -import "package:nexus/widgets/search_drawer.dart"; import "package:nexus/widgets/sync_status_banner.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; @@ -47,7 +45,6 @@ final class const RoomChat({ final highlightedEvent = useState(null); final composerSize = useState(64); - final typingIndicatorSize = useState(0); final userId = ref.watch(ClientStateController.provider)?.userId; final memberListOpened = ref @@ -85,11 +82,10 @@ final class const RoomChat({ ), ); - final ChatParams chat = ( + final controllerProvider = RoomChatController.provider(( roomId: roomId, contextualEvent: contextualEvent.value, - ); - final controllerProvider = RoomChatController.provider(chat); + )); final notifier = ref.watch(controllerProvider.notifier); final client = ref.read(ClientController.provider.notifier); @@ -202,148 +198,115 @@ final class const RoomChat({ ); return Scaffold( - endDrawerEnableOpenDragGesture: false, - endDrawer: SearchDrawer( + endDrawer: PinnedEventsDrawer( roomId, getEventOptions: getEventOptions, jumpToEvent: jumpToEvent, ), body: Builder( - builder: (searchContext) => Scaffold( - endDrawerEnableOpenDragGesture: false, - endDrawer: PinnedEventsDrawer( - roomId, - getEventOptions: getEventOptions, - jumpToEvent: jumpToEvent, + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer, ), - body: Builder( - builder: (middleContext) => Scaffold( - endDrawer: showMembersByDefault ? null : MemberList(roomId), - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: Scaffold.of(context).openDrawer, - onOpenMemberList: (thisContext) { - ref - .read(MemberListOpenedController.provider.notifier) - .set(!memberListOpened); - Scaffold.of(thisContext).openEndDrawer(); - }, - onOpenPinnedMessagesList: Scaffold.of(middleContext) - .openEndDrawer, - onOpenSearch: Scaffold.of(searchContext).openEndDrawer, - ), - body: Column( - children: [ - const SyncStatusBanner(), - Expanded( - child: Row( - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncLoading(value: _?) || - AsyncData(value: _) => ChatTimeline( - scroll: scroll, - chat: chat, - jumpToEvent: jumpToEvent, - getEventOptions: getEventOptions, - onSwipe: swipeToReply && canReply - ? (rowId) => onRelation(rowId, .reply) - : null, - highlightedEvent: highlightedEvent.value, - composerHeight: composerSize.value, - typingIndicatorHeight: - typingIndicatorSize.value, - ), - AsyncLoading _ => Loading(), - AsyncError( - :final error, - :final stackTrace, - ) => - ErrorDialog(error, stackTrace), - }, + body: Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + roomId: roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, ), - ), - AnimatedPositioned( - duration: Durations.short4, - bottom: composerSize.value, - left: 12, - // Leaves room for the jump to bottom button when it's shown - right: scroll.atBottom ? 12 : 72, - child: MeasureSize( - onChange: (size) => - typingIndicatorSize.value = size.height, - child: TypingIndicator(roomId), - ), - ), - Positioned( - right: 16, - bottom: composerSize.value, - child: IgnorePointer( - ignoring: scroll.atBottom, - child: AnimatedOpacity( - opacity: scroll.atBottom ? 0 : 1, - duration: Durations.short4, - child: FloatingActionButton.small( - onPressed: scroll.jumpToBottom, - child: const Icon( - Icons.keyboard_arrow_down, - ), - ), - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => - composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relatedEventRowId: - relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () { - relatedEvent.value = null; - composerNode.requestFocus(); - }, - ), - ), - ), - ], + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), ), - ), - - if (memberListOpened && showMembersByDefault) - MemberList(roomId), - ], + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: Durations.short4, + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => + composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relatedEventRowId: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + ), + ), + ], + ), ), - ), - ], + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), + ], + ), ), - ), + ], ), ), ), diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 38ad847..bc8e9ee 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -7,9 +7,7 @@ import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/controllers/room_settings_sections.dart"; import "package:nexus/widgets/adaptive_menu_button.dart"; -import "package:nexus/widgets/settings/settings_layout.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/models/room.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -17,7 +15,7 @@ import "package:nexus/main.dart"; final class const RoomMenuButton( final Room? room, { - final IList children = const .empty(), + final IList children = const IList.empty(), super.key, }) extends ConsumerWidget { @override @@ -124,21 +122,6 @@ final class const RoomMenuButton( title: Text("Invite"), ), ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (_) => SettingsLayout( - title: "Room Settings", - sectionsProvider: RoomSettingsSectionsController.provider( - room!.metadata!.id, - ), - ), - ), - child: ListTile( - leading: Icon(Icons.settings), - title: Text("Settings"), - ), - ), PopupMenuItem( onTap: () => context.showLeaveRoomSheet( room!.metadata!.id, diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index dd7bc9b..60c5baa 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -2,16 +2,12 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; -import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/invites.dart"; -import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/join_room.dart"; -import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/linkified_text.dart"; import "package:nexus/models/room_summary.dart"; @@ -19,17 +15,11 @@ class const RoomSummarySheet( final RoomSummary summary, { final IList? via, super.key, -}) extends ConsumerWidget { +}) extends StatelessWidget { @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; final isInvite = summary.membership == .invite; - final isKnocking = summary.membership == .knock; - final mustKnock = summary.mustKnock( - ref.watch( - RoomsController.provider.select((rooms) => rooms.keys.toISet()), - ), - ); final colorScheme = Theme.of(context).colorScheme; return SheetLayout( @@ -93,35 +83,7 @@ class const RoomSummarySheet( }, child: Text("Accept"), ), - ] else if (isKnocking) - TextButton( - onPressed: () { - final container = ProviderScope.containerOf(context); - Navigator.of(context).pop(); - - container - .read(ClientController.provider.notifier) - .leaveRoom(summary.roomId) - .onError(showError); - }, - style: TextButton.styleFrom(foregroundColor: colorScheme.error), - child: Text("Retract knock"), - ) - else if (via != null && mustKnock) - TextButton( - onPressed: () async { - if (await context.showAdaptiveSheet( - (_) => - KnockSheet(summary.roomId, name: name, via: via!), - ) == - true && - context.mounted) { - Navigator.of(context).pop(); - } - }, - child: Text("Knock"), - ) - else if (via != null) + ] else if (via != null) TextButton( onPressed: () { context.joinRoom( @@ -177,17 +139,6 @@ class const RoomSummarySheet( subtitle: LinkifiedText(summary.topic!), leading: Icon(Icons.info), ), - if (isKnocking) - ListTile( - title: Text("You've knocked"), - subtitle: Text("Waiting for a moderator to let you in"), - leading: Icon(Icons.hourglass_top), - ) - else if (mustKnock) - ListTile( - title: Text("Knock to join"), - leading: Icon(Icons.door_front_door), - ), if (summary.encryption != null) ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), if (summary.worldReadable == true) diff --git a/lib/widgets/search_drawer.dart b/lib/widgets/search_drawer.dart deleted file mode 100644 index 03418df..0000000 --- a/lib/widgets/search_drawer.dart +++ /dev/null @@ -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 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), - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/settings/add_users_power_level_sheet.dart b/lib/widgets/settings/add_users_power_level_sheet.dart deleted file mode 100644 index 2603fd5..0000000 --- a/lib/widgets/settings/add_users_power_level_sheet.dart +++ /dev/null @@ -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 existing, - - /// The user's own power level, which they can't set levels above. - required final double ownLevel, - required final void Function(ISet users, int level) onSave, - super.key, -}) extends HookConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final chosen = useState(const ISet.empty()); - final error = useState(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, - ), - ], - ); - } -} diff --git a/lib/widgets/settings/aliases_sheet.dart b/lib/widgets/settings/aliases_sheet.dart deleted file mode 100644 index d517f98..0000000 --- a/lib/widgets/settings/aliases_sheet.dart +++ /dev/null @@ -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 aliases, - required final String? canonical, - required final ISet alternatives, - - /// The canonical alias, and the other published ones. - required final void Function(String? canonical, ISet 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); - }, - ), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/settings/allowed_rooms_sheet.dart b/lib/widgets/settings/allowed_rooms_sheet.dart deleted file mode 100644 index 6ba42fb..0000000 --- a/lib/widgets/settings/allowed_rooms_sheet.dart +++ /dev/null @@ -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 allowed, - - /// Suggested first, as they're the usual choice. - required final ISet parentSpaces, - required final void Function(ISet allowed) onSave, - super.key, -}) extends HookConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final chosen = useState(allowed); - final error = useState(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 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, - ), - ], - ); - } -} diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index b31182f..d802d7b 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -1,9 +1,6 @@ -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:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/widgets/settings/radio_sheet.dart"; final class const DialogListTile({ @@ -11,44 +8,32 @@ final class const DialogListTile({ required final Icon icon, required final String title, required final List options, - - /// While a returned future runs, the tile is disabled. - required final FutureOr Function(T value)? onChanged, + required final void Function(T value)? onChanged, required final String Function(T option) getName, final Widget? subtitle, super.key, -}) extends HookWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) { - final saving = Saving.use(); - Future change(T value) => saving.run(() => onChanged!(value)); - - return ListTile( - enabled: onChanged != null && !saving.active, - onTap: () => context.showAdaptiveSheet( - (context) => RadioSheet( - title: title, - getName: getName, - onChanged: onChanged == null ? null : change, - value: value, - options: options, - ), + Widget build(BuildContext context, WidgetRef ref) => ListTile( + enabled: onChanged != null, + onTap: () => context.showAdaptiveSheet( + (context) => RadioSheet( + title: title, + getName: getName, + onChanged: onChanged, + value: value, + options: options, ), - title: Text(title), - subtitle: subtitle, - leading: icon, - trailing: ConstrainedBox( - constraints: const .new(maxWidth: 160), - child: Chip( - label: Text( - value is T ? getName(value as T) : "None", - overflow: TextOverflow.ellipsis, - style: onChanged == null || saving.active - ? .new(color: Colors.grey) - : null, - ), - ), + ), + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: Chip( + label: Text( + value == null ? "None" : getName(value as T), + overflow: TextOverflow.ellipsis, + style: onChanged == null ? .new(color: Colors.grey) : null, ), - ); - } + ), + ); } diff --git a/lib/widgets/settings/local_aliases_sheet.dart b/lib/widgets/settings/local_aliases_sheet.dart deleted file mode 100644 index def7db9..0000000 --- a/lib/widgets/settings/local_aliases_sheet.dart +++ /dev/null @@ -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 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(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, - ), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/settings/power_level_entry_sheet.dart b/lib/widgets/settings/power_level_entry_sheet.dart deleted file mode 100644 index 9d5e457..0000000 --- a/lib/widgets/settings/power_level_entry_sheet.dart +++ /dev/null @@ -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, - ), - ], - ); - } -} diff --git a/lib/widgets/settings/power_level_field.dart b/lib/widgets/settings/power_level_field.dart deleted file mode 100644 index fe539bf..0000000 --- a/lib/widgets/settings/power_level_field.dart +++ /dev/null @@ -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 = (2 ^ 53) - 1; - - /// The level in [text], if it's one someone with [ownLevel] can set. - static int? levelFrom(String text, double ownLevel) => switch (int.tryParse( - text, - )) { - final level? when level.abs() <= maxMagnitude && level <= ownLevel => level, - _ => null, - }; - - @override - Widget build(BuildContext context) { - final text = useValueListenable(controller).text; - - return TextField( - controller: controller, - autofocus: autofocus, - keyboardType: .numberWithOptions(signed: true), - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))], - onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(), - decoration: .new( - labelText: "Power level", - errorText: switch (int.tryParse(text)) { - _ when text.isEmpty || text == "-" => null, - final level? when level.abs() <= maxMagnitude => - level > ownLevel ? "Can't be above your own power level" : null, - // Including ones too far from zero to even parse - _ => "Must be from -$maxMagnitude to $maxMagnitude", - }, - ), - ); - } -} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart deleted file mode 100644 index 5b7d4e6..0000000 --- a/lib/widgets/settings/power_level_tile.dart +++ /dev/null @@ -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 remove() async { - if (removeConfirmation case final confirmation?) { - final confirmed = await context.showAdaptiveSheet( - (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, - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/widgets/settings/radio_sheet.dart b/lib/widgets/settings/radio_sheet.dart index 9f9512e..051b67a 100644 --- a/lib/widgets/settings/radio_sheet.dart +++ b/lib/widgets/settings/radio_sheet.dart @@ -12,8 +12,7 @@ final class const RadioSheet({ }) extends HookWidget { @override Widget build(BuildContext context) { - // Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet - final chosen = useState<(T,)?>(null); + final mutValue = useState(null); return SheetLayout( title: Text(title), actions: [ @@ -21,7 +20,7 @@ final class const RadioSheet({ if (onChanged != null) TextButton( onPressed: () { - if (chosen.value case (final picked,)) onChanged!(picked); + if (mutValue.value != null) onChanged!(mutValue.value as T); Navigator.of(context).pop(); }, child: Text("OK"), @@ -29,11 +28,8 @@ final class const RadioSheet({ ], children: [ RadioGroup( - groupValue: switch (chosen.value) { - (final picked,) => picked, - null => value, - }, - onChanged: (value) => chosen.value = (value as T,), + groupValue: mutValue.value ?? value, + onChanged: (value) => mutValue.value = value ?? mutValue.value, child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/settings/search_chooser.dart b/lib/widgets/settings/search_chooser.dart deleted file mode 100644 index feadcdd..0000000 --- a/lib/widgets/settings/search_chooser.dart +++ /dev/null @@ -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 chosen, - - required final IList 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]), - ), - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart deleted file mode 100644 index 7f6c527..0000000 --- a/lib/widgets/settings/settings_layout.dart +++ /dev/null @@ -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>> - > - sectionsProvider, - final List 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, - ), - ); - }, - ), - ); -} diff --git a/lib/widgets/settings/text_setting_tile.dart b/lib/widgets/settings/text_setting_tile.dart deleted file mode 100644 index 6f97eb0..0000000 --- a/lib/widgets/settings/text_setting_tile.dart +++ /dev/null @@ -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 Function(String value)? onChanged, - final bool multiline = false, - super.key, -}) extends HookWidget { - @override - Widget build(BuildContext context) { - final saving = Saving.use(); - Future 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), - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/widgets/sheet_layout.dart b/lib/widgets/sheet_layout.dart index 9a15a1d..0ce98d1 100644 --- a/lib/widgets/sheet_layout.dart +++ b/lib/widgets/sheet_layout.dart @@ -9,34 +9,22 @@ class const SheetLayout({ @override Widget build(BuildContext context) => Padding( 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( + child: SingleChildScrollView( + padding: const .all(24), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + if (title case final title?) + DefaultTextStyle( style: Theme.of(context).textTheme.headlineSmall!, child: title, ), - ), - Flexible( - child: SingleChildScrollView( - padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - spacing: 12, - children: children, - ), - ), - ), - Padding( - padding: const .fromLTRB(24, 0, 24, 24), - child: Row(mainAxisAlignment: .end, spacing: 8, children: actions), - ), - ], + ...children, + Row(mainAxisAlignment: .end, spacing: 8, children: actions), + ], + ), ), ); } diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 33a891d..85ae46c 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -14,7 +14,6 @@ import "package:nexus/models/content/create.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; -import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -48,14 +47,6 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), child: const Text("Leave"), ) - : room.summary.mustKnock(joinedIds) - ? TextButton( - onPressed: () => context.showAdaptiveSheet( - (_) => - KnockSheet(room.summary.roomId, name: name, via: child.via), - ), - child: const Text("Knock"), - ) : TextButton( onPressed: () => context.joinRoom( .new(roomIdOrAlias: room.summary.roomId, via: child.via), diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index bcab7fc..b6da63d 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,5 +1,4 @@ import "package:collection/collection.dart"; -import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -8,18 +7,14 @@ import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/mutual_rooms.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/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/membership_action.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; @@ -27,6 +22,7 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/start_dm_sheet.dart"; final class const UserBottomSheet( + final MembershipContent member, final String userId, { final String? roomId, super.key, @@ -39,11 +35,6 @@ final class const UserBottomSheet( color: theme.colorScheme.onSurfaceVariant, ); final client = ref.read(ClientController.provider.notifier); - // Green for accepting knocks, harmonized so it fits the current theme - final acceptScheme = ColorScheme.fromSeed( - seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), - brightness: theme.brightness, - ); void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( @@ -91,248 +82,181 @@ final class const UserBottomSheet( ), ); - return switch (ref.watch( - UserController.provider(.new(roomId: roomId, userId: userId)), - )) { - AsyncError(:final error, :final stackTrace) => ErrorDialog( - error, - stackTrace, - ), - AsyncData(value: final member) || - AsyncLoading(value: final member?) => SingleChildScrollView( - child: Padding( - padding: .all(42), - child: Column( - spacing: 4, - mainAxisSize: .min, - crossAxisAlignment: .center, - children: [ + return SingleChildScrollView( + child: Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + SizedBox(height: 18), + + ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText(userId, textAlign: .center, style: subtitleStyle), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: () => Text(""), + data: (profileResponse) => Column( + children: [ + if (profileResponse.profile.timezone == null && + profileResponse.profile.pronouns.isEmpty) + Text(""), + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profileResponse.profile.pronouns + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: subtitleStyle, + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != null) ...[ + if (profileResponse.profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profileResponse.profile.timezone!, + textAlign: .center, + style: subtitleStyle, + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != + ref.watch(ClientStateController.provider)?.userId) ...[ Row( - mainAxisAlignment: .end, children: [ - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), + Expanded( + child: M3EButton.icon( + onPressed: () async { + final name = member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any((room) => room.metadata?.dmUserId == userId); + + if (hasDm) { + context.startDirectMessage(userId, name: name); + } else if (await context.showAdaptiveSheet( + (_) => StartDmSheet(userId: userId, name: name), + ) != + true) { + return; + } + + if (context.mounted) Navigator.of(context).pop(); + }, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), ), ], ), - SizedBox(height: 18), - ExpandableImage( - member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), - downloadFilename: member.displayName ?? userId.localpart, - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - dimension: 200, - ), - ), - - SizedBox(height: 8), - - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineLarge, - textAlign: .center, - ), - SelectableText(userId, textAlign: .center, style: subtitleStyle), - - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: () => Text(""), - data: (profileResponse) => Column( - children: [ - Wrap( - crossAxisAlignment: .center, - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - ...profileResponse.profile.pronouns - .where( - // TODO: Check system language (l10n) - (pronoun) => pronoun.language == "en", - ) - .mapIndexed( - (index, pronoun) => [ - if (index != 0) - Icon( - Icons.circle, - size: 4, - color: - theme.colorScheme.onSurfaceVariant, - ), - Text( - pronoun.summary, - textAlign: .center, - style: subtitleStyle, - ), - ], - ) - .flattened, - - if (profileResponse.profile.timezone != null) ...[ - if (profileResponse.profile.pronouns.isNotEmpty) - SizedBox( - height: 16, - child: VerticalDivider( - thickness: 1.5, - width: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - profileResponse.profile.timezone!, - textAlign: .center, - style: subtitleStyle, - ), - ], - ], - ), - ], - ), - ), - - if (userId != ref.watch(ClientStateController.provider)?.userId) - switch (ref.watch(MutualRoomsController.provider(userId))) { - AsyncData(value: final roomIds) => switch (ref.watch( - RoomsController.provider.select( - (rooms) => roomIds.where(rooms.containsKey).length, - ), - )) { - 0 => SizedBox.shrink(), - final count => TextButton( - onPressed: () => context.showAdaptiveSheet( - (_) => MutualRoomsSheet(userId), - ), - child: Text( - "$count mutual ${Intl.plural(count, one: "room", other: "rooms")}", + if (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, ), ), - }, - _ => SizedBox.shrink(), - }, - - SizedBox(height: 8), - if (userId != - ref.watch(ClientStateController.provider)?.userId) ...[ - Row( - children: [ - Expanded( - child: M3EButton.icon( - onPressed: () async { - final name = member.displayName ?? userId.localpart; - final hasDm = ref - .read(RoomsController.provider) - .values - .any((room) => room.metadata?.dmUserId == userId); - - if (hasDm) { - context.startDirectMessage(userId, name: name); - } else if (await context.showAdaptiveSheet( - (_) => StartDmSheet(userId: userId, name: name), - ) != - true) { - return; - } - - if (context.mounted) Navigator.of(context).pop(); - }, - shape: .square, - style: .tonal, - icon: Icon(Icons.message), - label: Text("Message"), - ), - ), - ], - ), - - 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, - ), - ), - ), - ], - ), - ), - - if (roomId != null && - ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - )) - Padding( - padding: .only(top: 4), - child: Row( - spacing: 8, - children: [ - if (member.status case .join || .invite || .knock) - M3EButton.icon( - onPressed: () => showMembershipSheet(.kick), - shape: .square, - icon: Icon(Icons.sports_martial_arts), - label: Text("Kick"), - decoration: .styleFrom( - backgroundColor: theme.colorScheme.error, - foregroundColor: theme.colorScheme.onError, - ), - ), - + )) + Padding( + padding: .only(top: 4), + child: Row( + spacing: 8, + children: [ + if (member.status == .join || member.status == .invite) M3EButton.icon( - onPressed: () => showMembershipSheet(.ban), + onPressed: () => showMembershipSheet(.kick), shape: .square, - icon: Icon(Icons.gavel), - label: Text("Ban"), + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), decoration: .styleFrom( - backgroundColor: theme.colorScheme.errorContainer, - foregroundColor: theme.colorScheme.onErrorContainer, + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, ), ), - ].map((e) => Expanded(child: e)).toList(), - ), + + M3EButton.icon( + onPressed: () => showMembershipSheet(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + ), + ].map((e) => Expanded(child: e)).toList(), ), - ], + ), ], - ), + ], ), ), - AsyncLoading _ => Loading(), - }; + ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 077376d..b92986a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) app_links_linux_registrar = @@ -33,7 +35,13 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 66d7f77..553a7ae 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,7 +9,9 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux media_kit_video pasteboard + screen_retriever_linux url_launcher_linux + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index a47f824..6b2b94d 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -36,13 +36,7 @@ pkgs.mkShell { }) android.platform-tools ] - ++ lib.optionals pkgs.stdenv.isLinux [ - wayland - # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately - libxext - libxfixes - (callPackage ./pkg/hook_pkg_config.nix { }) - ]; + ++ lib.optional pkgs.stdenv.isLinux wayland; env = rec { LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index bb6b2bf..3e13fcb 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -3,8 +3,6 @@ callPackage, mpv-unwrapped, libass, - libxext, - libxfixes, libclang, flutter, stdenv, @@ -27,14 +25,9 @@ flutter.buildFlutterApplication { "--dart-define=GIT_COMMIT=${commit}" ]; - nativeBuildInputs = [ (callPackage ./hook_pkg_config.nix { }) ]; - buildInputs = [ mpv-unwrapped libass - # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately - libxext - libxfixes ]; env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; @@ -49,10 +42,9 @@ flutter.buildFlutterApplication { m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY="; navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; - unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; - unifiedpush_platform_interface = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; + unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; + unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; webcrypto = "sha256-HW0NukbddwOYd9iVUFA3L8iy1qggnyOBgcbMj0MTYss="; - window_manager = "sha256-xUd5hG+/V6F+m0ZUL/3XGPACFG/zqI5JM6IaMVvLgx4="; xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; }; diff --git a/linux/nix/pkg/hook_pkg_config.nix b/linux/nix/pkg/hook_pkg_config.nix deleted file mode 100644 index b5219e5..0000000 --- a/linux/nix/pkg/hook_pkg_config.nix +++ /dev/null @@ -1,32 +0,0 @@ -{ - stdenvNoCC, - pkg-config, - pkg-config-unwrapped, - makeWrapper, - gtk3, - libx11, - libxi, -}: - -# Build hooks run with a filtered environment that drops PKG_CONFIG_PATH, so -# nativeapi's hook can only find its libraries through a pkg-config on PATH. -stdenvNoCC.mkDerivation { - name = "hook-pkg-config"; - dontUnpack = true; - - nativeBuildInputs = [ - pkg-config - makeWrapper - ]; - - buildInputs = [ - gtk3 - libx11 - libxi - ]; - - installPhase = '' - makeWrapper ${pkg-config-unwrapped}/bin/pkg-config $out/bin/pkg-config \ - --prefix PKG_CONFIG_PATH : "$PKG_CONFIG_PATH" - ''; -} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index f5795d0..bf6e5ef 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -155,6 +155,11 @@ static void my_application_activate( } #endif + gtk_widget_set_size_request( + GTK_WIDGET(window), + 250, + -1); + if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f10315f..cc20897 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,9 +13,11 @@ import media_kit_libs_macos_video import media_kit_video import package_info_plus import pasteboard +import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -26,7 +28,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index d3101a9..05ea34c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,14 +217,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" - cnativeapi: - dependency: transitive - description: - name: cnativeapi - sha256: "251fb93cd7ec205445fa512ee5ddeb0b82b3273d071cfc0784ddffbbdebbf5d3" - url: "https://pub.dev" - source: hosted - version: "0.5.2" code_assets: dependency: "direct main" description: @@ -1049,22 +1041,6 @@ packages: url: "https://github.com/Henry-Hiles/native_toolchain_cmake" source: git version: "0.3.2" - nativeapi: - dependency: transitive - description: - name: nativeapi - sha256: c4625e81f5ce2f4a9044c0ec1bdb38355119ffcf89e24eb20ae9fc5027a01e59 - url: "https://pub.dev" - source: hosted - version: "0.5.2" - nativeapi_flutter: - dependency: transitive - description: - name: nativeapi_flutter - sha256: "4feaa68cb5c4fc3b2eeaee1a25e56e2d092178e1c59516d9db5dd34940dcf36c" - url: "https://pub.dev" - source: hosted - version: "0.5.2" navigation_rail_m3e: dependency: "direct main" description: @@ -1306,6 +1282,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.6" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" shared_preferences: dependency: "direct main" description: @@ -1523,8 +1539,8 @@ packages: dependency: "direct overridden" description: path: unifiedpush_linux - ref: "quad/nexus" - resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" + ref: "quad/feat/should-write-service-option" + resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" url: "https://codeberg.org/Henry-Hiles/flutter-connector" source: git version: "1.0.1" @@ -1532,8 +1548,8 @@ packages: dependency: "direct overridden" description: path: unifiedpush_platform_interface - ref: "quad/nexus" - resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" + ref: "quad/feat/should-write-service-option" + resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" url: "https://codeberg.org/Henry-Hiles/flutter-connector" source: git version: "4.0.0" @@ -1757,12 +1773,11 @@ packages: window_manager: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "80acd536a1ea1c121c36e32d30e638a295087ed1" - url: "https://github.com/leanflutter/window_manager" - source: git - version: "0.6.0" + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" xdg_desktop_portal: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 47df5ce..4eedbd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,20 +41,17 @@ dependency_overrides: unifiedpush_linux: git: url: https://codeberg.org/Henry-Hiles/flutter-connector - ref: quad/nexus + ref: quad/feat/should-write-service-option path: unifiedpush_linux unifiedpush_platform_interface: git: url: https://codeberg.org/Henry-Hiles/flutter-connector - ref: quad/nexus + ref: quad/feat/should-write-service-option path: unifiedpush_platform_interface linkify: git: url: https://github.com/appelladev/linkify ref: fix/consecutive-periods-loose-url - window_manager: - git: - url: https://github.com/leanflutter/window_manager dependencies: flutter: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7dc1c3c..1d53353 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -27,6 +29,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 68413e7..05e3fad 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,7 +9,9 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_windows_video media_kit_video pasteboard + screen_retriever_windows url_launcher_windows + window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST