diff --git a/gomuks b/gomuks index ba82b6e..53cae0e 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f +Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 8432468..fef1c16 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -8,6 +8,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/cupertino.dart"; import "package:intl/intl.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; @@ -41,6 +42,7 @@ import "package:nexus/models/requests/paginate_manual.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; +import "package:nexus/models/requests/search.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/set_account_data.dart"; @@ -105,39 +107,50 @@ class ClientController extends AsyncNotifier { return handle; } + /// Calls gomuks with [data] as JSON, returning its decoded response. Future callGomuksMethod( Map data, - FutureOr Function(int handle, GomuksBorrowedBuffer data) - callback, + void Function( + int handle, + GomuksOwnedBuffer data, + Pointer> callback, + ) + call, ) async { - final bufferPointer = data.toGomuksBufferPtr(); + 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); + } + }); try { - final handle = await 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); + call(handle, buffer, callable.nativeFunction); + } catch (_) { + // gomuks never got the call, so it won't respond or free the buffer + callable.close(); + GomuksFreeBuffer(buffer); + rethrow; } + + return completer.future; } Future handlePush(Map data) async { - final response = await callGomuksMethod( - data, - (handle, data) async => GomuksHandlePush(handle, data), - ); + final response = await callGomuksMethod(data, GomuksHandlePushAsync); return response?["notification"] == null ? null @@ -147,11 +160,12 @@ class ClientController extends AsyncNotifier { Future _sendCommand( String command, [ Map data = const {}, - ]) => callGomuksMethod(data, (handle, data) { + ]) => callGomuksMethod(data, (handle, data, callback) { final commandPointer = command.toNativeUtf8().cast(); try { - return GomuksSubmitCommand(handle, commandPointer, data); + GomuksSubmitCommandAsync(handle, commandPointer, data, callback); } finally { + // gomuks copies the command name before returning calloc.free(commandPointer); } }); @@ -222,6 +236,9 @@ class ClientController extends AsyncNotifier { 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"]; @@ -273,6 +290,12 @@ 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())); @@ -310,6 +333,11 @@ 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)]; @@ -317,9 +345,14 @@ 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": "m.read", + "receipt_type": sendPublic ? "m.read" : "m.read.private", "event_id": event.eventId, }); } @@ -338,6 +371,34 @@ 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 new file mode 100644 index 0000000..ea78567 --- /dev/null +++ b/lib/controllers/event_search.dart @@ -0,0 +1,97 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/requests/search.dart"; + +typedef EventSearchParams = ({String roomId, String query}); +typedef EventSearchResults = ({IList 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 9d742fa..a97599b 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/sync_status.dart"; import "package:nexus/controllers/top_level_spaces.dart"; +import "package:nexus/controllers/typing.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/main.dart"; import "package:ffi/ffi.dart"; @@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier { event.roomId: .new(events: .new({event.rowId: event})), }), ); + case "typing": + ref + .read(TypingController.provider.notifier) + .set(.fromJson(decodedMuksEvent)); case "events_decrypted": final decrypted = EventsDecrypted.fromJson(decodedMuksEvent); final metadata = ref @@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier { ref.invalidate(roomProvider); ref.invalidate(accountDataProvider); ref.invalidate(invitesProvider); + ref.invalidate(TypingController.provider); } ref @@ -153,6 +159,7 @@ 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 new file mode 100644 index 0000000..e80d4bb --- /dev/null +++ b/lib/controllers/local_aliases.dart @@ -0,0 +1,28 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +/// A room's aliases on the user's own server. +class LocalAliasesController(final String roomId) + extends AsyncNotifier> { + @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 f903ec4..a668896 100644 --- a/lib/controllers/members_grouped.dart +++ b/lib/controllers/members_grouped.dart @@ -1,44 +1,27 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/members_by_status.dart"; -import "package:nexus/controllers/room_creators.dart"; -import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/models/configs/members_by_status.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/event.dart"; class MembersGroupedController(final MembersByStatusConfig config) extends AsyncNotifier>>> { @override Future>>> build() async { - final room = ref.watch( - RoomsController.provider.select((value) => value[config.roomId]), + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), ); - final roomCreators = room == null - ? null - : ref.watch((RoomCreatorsController.provider(room))); - - final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""]; - final powerLevelsEvent = powerLevelsRowId == null - ? null - : room?.events[powerLevelsRowId]; - - final content = switch (powerLevelsEvent?.content) { - PowerLevelsContent content => content, - _ => PowerLevelsContent(), - }; - final members = await ref.watch( MembersByStatusController.provider(config).future, ); return members .fold>>(.new(), (result, event) { - final groupKey = roomCreators?.contains(event.stateKey!) == true + final groupKey = powerLevels.creators.contains(event.stateKey!) ? null - : content.users[event.stateKey!] ?? content.usersDefault; + : powerLevels.levelOf(event.stateKey!).toInt(); return result.update( groupKey, diff --git a/lib/controllers/mutual_rooms.dart b/lib/controllers/mutual_rooms.dart new file mode 100644 index 0000000..da4086a --- /dev/null +++ b/lib/controllers/mutual_rooms.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client.dart"; + +class MutualRoomsController(final String userId) + extends AsyncNotifier> { + @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 4140b66..e38ffae 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -1,4 +1,3 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; @@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); class NotificationsController(final NotificationsRequest request) extends AsyncNotifier> { - 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); @@ -43,7 +22,9 @@ class NotificationsController(final NotificationsRequest request) ), ); - storeEvents(mentions.events.addAll(mentions.relatedEvents)); + ref + .read(RoomsController.provider.notifier) + .storeEvents(mentions.events.addAll(mentions.relatedEvents)); return .new(mentions.events.map((event) => (event.rowId, event.roomId))); } @@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request) ), ); - storeEvents( - newNotifications.events.addAll(newNotifications.relatedEvents), - ); + ref + .read(RoomsController.provider.notifier) + .storeEvents( + newNotifications.events.addAll(newNotifications.relatedEvents), + ); return currentNotifications.addAll( newNotifications.events.map((event) => (event.rowId, event.roomId)), diff --git a/lib/controllers/power_level.dart b/lib/controllers/power_level.dart index 7111e38..6fe9d68 100644 --- a/lib/controllers/power_level.dart +++ b/lib/controllers/power_level.dart @@ -1,10 +1,8 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/room_creators.dart"; -import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/models/configs/power_level.dart"; import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/power_levels.dart"; class PowerLevelController(final PowerLevelConfig config) extends Notifier { @@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config) ); } - final room = ref.watch( - RoomsController.provider.select((value) => value[config.roomId]), + final powerLevels = ref.watch( + RoomPowerLevelsController.provider(config.roomId), ); - - final roomCreators = room == null - ? null - : ref.watch(RoomCreatorsController.provider(room)); - - final eventRowId = room?.state[EventType.powerLevels.type]?[""]; - - final event = eventRowId == null ? null : room?.events[eventRowId]; - final content = event?.content is PowerLevelsContent - ? event!.content - : PowerLevelsContent(); + final content = powerLevels.content; final user = ref.watch( ClientStateController.provider.select((value) => value?.userId), ); - if (user == null || content is! PowerLevelsContent) return false; + if (user == null) return false; - double powerLevelOf(String userId) => roomCreators?.contains(userId) == true - ? double.infinity - : (content.users[userId] ?? content.usersDefault).toDouble(); - - final userLevel = powerLevelOf(user); + final userLevel = powerLevels.levelOf(user); return switch (config) { EventPowerLevelConfig(:final eventType) => @@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config) .invite => userLevel >= content.invite, .kick => - userLevel >= content.kick && userLevel > powerLevelOf(targetUser), + userLevel >= content.kick && + userLevel > powerLevels.levelOf(targetUser), .ban => - userLevel >= content.ban && userLevel > powerLevelOf(targetUser), + userLevel >= content.ban && + userLevel > powerLevels.levelOf(targetUser), .unban => userLevel >= content.ban, }, diff --git a/lib/controllers/power_levels_saving.dart b/lib/controllers/power_levels_saving.dart new file mode 100644 index 0000000..d20d326 --- /dev/null +++ b/lib/controllers/power_levels_saving.dart @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..15f76aa --- /dev/null +++ b/lib/controllers/read_receipts.dart @@ -0,0 +1,90 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/is_shown_as_message.dart"; +import "package:nexus/models/read_receipt.dart"; + +/// Other users' read receipts in a chat, by the event ID of the message to +/// show them under: the nearest message at or before what they've read. +class ReadReceiptsController(final ChatParams chat) + extends Notifier>> { + @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 c4a8dda..b8b688d 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -73,16 +73,7 @@ class RoomChatController(final ChatParams info) .addAll(context.after); ref .read(RoomsController.provider.notifier) - .update( - .new({ - info.roomId: Room( - events: IMap.fromIterable( - events.addAll(context.relatedEvents), - keyMapper: (event) => event.rowId, - ), - ), - }), - ); + .storeEvents(events.addAll(context.relatedEvents)); return .new( timeline: .new(events.map((element) => element.rowId)), hasMoreBackward: true, @@ -165,6 +156,7 @@ class RoomChatController(final ChatParams info) valueMapper: (event) => event, ), hasMore: response.hasMore, + receipts: response.receipts, timeline: IMap.fromIterable( response.events, keyMapper: (event) => event.timelineRowId, @@ -186,17 +178,8 @@ class RoomChatController(final ChatParams info) ref .read(RoomsController.provider.notifier) - .update( - .new({ - info.roomId: Room( - events: IMap.fromIterable( - paginationResponse.events.addAll( - paginationResponse.relatedEvents, - ), - keyMapper: (event) => event.rowId, - ), - ), - }), + .storeEvents( + paginationResponse.events.addAll(paginationResponse.relatedEvents), ); final eventRowIds = diff --git a/lib/controllers/room_creators.dart b/lib/controllers/room_creators.dart deleted file mode 100644 index 09acdd7..0000000 --- a/lib/controllers/room_creators.dart +++ /dev/null @@ -1,30 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/content/content.dart"; -import "package:nexus/models/content/create.dart"; -import "package:nexus/models/room.dart"; - -class RoomCreatorsController(final Room room) extends Notifier> { - @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 new file mode 100644 index 0000000..e33c436 --- /dev/null +++ b/lib/controllers/room_power_levels.dart @@ -0,0 +1,59 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/room_power_levels.dart"; + +class RoomPowerLevelsController(final String roomId) + extends Notifier { + @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 new file mode 100644 index 0000000..74f0d9f --- /dev/null +++ b/lib/controllers/room_settings_sections.dart @@ -0,0 +1,817 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/local_aliases.dart"; +import "package:nexus/controllers/power_level.dart"; +import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/setting.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; +import "package:nexus/widgets/settings/aliases_sheet.dart"; +import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; +import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/local_aliases_sheet.dart"; +import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; +import "package:nexus/widgets/settings/power_level_tile.dart"; +import "package:nexus/widgets/settings/text_setting_tile.dart"; + +class RoomSettingsSectionsController(final String roomId) + extends AsyncNotifier>> { + @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 new file mode 100644 index 0000000..887a8b2 --- /dev/null +++ b/lib/controllers/room_typing.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/controllers/typing.dart"; + +/// The other users typing in a room, or none if typing indicators are hidden +/// in settings. +class RoomTypingController(final String roomId) + extends Notifier> { + @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 7a5c181..5370bcf 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -1,7 +1,10 @@ +import "dart:async"; + import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/room.dart"; class RoomsController extends Notifier> { @@ -33,9 +36,49 @@ 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 ISet.empty(), + ISet leftRooms = const .empty(), ]) { final merged = rooms.entries.fold(state, (acc, entry) { final roomId = entry.key; @@ -76,21 +119,42 @@ class RoomsController extends Notifier> { timeline: incoming.reset ? incoming.timeline : existing.timeline.addAll(incoming.timeline), - receipts: incoming.receipts.entries.fold( - existing.receipts, - (receiptAcc, event) => receiptAcc.add( - event.key, - (receiptAcc[event.key] ?? .new()).addAll(event.value), - ), - ), + receipts: mergeReceipts(existing.receipts, incoming.receipts), ) ?? - incoming, + incoming.copyWith( + receipts: mergeReceipts(const .empty(), incoming.receipts), + ), ); }); state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId)); } + /// Waits for a sent state event to become the room's state, which happens + /// once it comes down sync. Gives up after a while, e.g. if another event + /// replaced it first. + Future 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 1ca07a8..665ecb1 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -19,7 +19,9 @@ import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; +import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; @@ -31,6 +33,30 @@ class SettingsSectionsController Future>> 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( @@ -151,7 +177,7 @@ class SettingsSectionsController ), .new( title: "Linux Mobile Mode", - description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.", + description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.", icon: Icons.construction, builder: (title, description, icon) => SwitchListTile( title: Text(title), @@ -166,6 +192,51 @@ class SettingsSectionsController : null, ), ), + .new( + title: "Hide Membership Events", + description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.", + icon: Icons.person_off, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.hideMembershipEvents, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(hideMembershipEvents: value)) + .onError(showError), + ), + ), + ]), + ), + .new( + title: "Privacy", + icon: Icons.privacy_tip, + settings: .new([ + .new( + title: "Typing Indicators", + description: "Show when others are typing, and let others see when you're typing.", + icon: Icons.keyboard, + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.typingIndicators, + update: (value) => settings.copyWith(typingIndicators: value), + ), + ), + .new( + title: "Read Receipts", + description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.", + icon: Icons.done_all, + builder: (title, description, icon) => showAndSendTile( + title, + description, + icon, + value: settings.readReceipts, + update: (value) => settings.copyWith(readReceipts: value), + ), + ), ]), ), ]), diff --git a/lib/controllers/typing.dart b/lib/controllers/typing.dart new file mode 100644 index 0000000..d1f3d28 --- /dev/null +++ b/lib/controllers/typing.dart @@ -0,0 +1,15 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/typing.dart"; + +class TypingController extends Notifier>> { + @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 67ef8c2..40c4666 100644 --- a/lib/controllers/unified_push.dart +++ b/lib/controllers/unified_push.dart @@ -88,7 +88,8 @@ class UnifiedPushController extends AsyncNotifier { if (notification == null || (!isInBackground && - await windowManager.isFocused().onError((_, _) => true) && + (!Platform.isAndroid && + (WindowManager.instance.getCurrent()?.isFocused ?? true)) && ref.read(SelectedRoomController.provider) == notification.roomId)) { if (isInBackground) exit(0); diff --git a/lib/helpers/extensions/better_when.dart b/lib/helpers/extensions/better_when.dart index beaae85..e054d02 100644 --- a/lib/helpers/extensions/better_when.dart +++ b/lib/helpers/extensions/better_when.dart @@ -8,10 +8,12 @@ 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 10a273a..57425be 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -19,19 +19,21 @@ 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 jsonString = json.encode(this); - final bytes = utf8.encode(jsonString); + final bytes = utf8.encode(json.encode(this)); final dataPtr = calloc(bytes.length); dataPtr.asTypedList(bytes.length).setAll(0, bytes); - final ptr = calloc(); - - ptr.ref - ..base = dataPtr - ..length = bytes.length; - - return ptr; + return calloc() + ..ref.base = dataPtr + ..ref.length = bytes.length; } } diff --git a/lib/helpers/extensions/is_shown_as_message.dart b/lib/helpers/extensions/is_shown_as_message.dart new file mode 100644 index 0000000..a78810b --- /dev/null +++ b/lib/helpers/extensions/is_shown_as_message.dart @@ -0,0 +1,15 @@ +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/content/sticker.dart"; +import "package:nexus/models/event.dart"; + +extension IsShownAsMessage on Event { + /// Whether this is shown in the timeline as a message, with MessageRenderer. + bool get isShownAsMessage => + redactedBy == null && + relationType != "m.replace" && + switch (content) { + MessageContent() || EncryptedContent() || StickerContent() => true, + _ => false, + }; +} diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart index be5d6a5..df939c1 100644 --- a/lib/helpers/extensions/join_room.dart +++ b/lib/helpers/extensions/join_room.dart @@ -40,4 +40,29 @@ extension JoinRoom on BuildContext { return roomId; } + + /// Knocks on a room, success and errors in snackbars. + /// Returns the room's ID, or null if knocking failed. + Future 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 b1bfeff..a56417e 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,14 +1,8 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; -import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover( - MembershipContent member, - String userId, { - String? roomId, - }) => showAdaptivePopover( - (context) => UserBottomSheet(member, userId, roomId: roomId), - ); + void showUserPopover(String userId, {String? roomId}) => + showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); } diff --git a/lib/helpers/hooks/saving.dart b/lib/helpers/hooks/saving.dart new file mode 100644 index 0000000..3a0faef --- /dev/null +++ b/lib/helpers/hooks/saving.dart @@ -0,0 +1,25 @@ +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 94469cc..5cd2129 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -83,14 +83,10 @@ void main(List args) async { exit(0); } else { if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) { - await windowManager.ensureInitialized(); - await windowManager.waitUntilReadyToShow( - WindowOptions( - titleBarStyle: TitleBarStyle.hidden, - windowButtonVisibility: false, - ), - ); - await windowManager.setMinimumSize(Size.square(500)); + WindowManager.instance.getCurrent() + ?..titleBarStyle = .hidden + ..isWindowControlButtonsVisible = false + ..minimumSize = Size.square(300).toNative(); } runApp( @@ -111,10 +107,21 @@ class const App({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => DynamicColorBuilder( builder: (lightDynamic, darkDynamic) => Consumer( - builder: (context, ref, child) => MaterialApp( - navigatorKey: navigatorKey, - debugShowCheckedModeBanner: false, - theme: + builder: (context, ref, child) { + // Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android + ThemeData forPlatform(ThemeData theme) => + ref.watch( + SettingsController.provider.select( + (settings) => settings.value?.linuxMobileMode ?? false, + ), + ) + ? theme.copyWith(platform: .android) + : theme; + + return MaterialApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + theme: forPlatform( (ref .watch(SettingsController.provider) .maybeWhen( @@ -124,7 +131,8 @@ class const App({super.key}) extends StatelessWidget { ) ?? ThemeData.light().colorScheme) .theme, - darkTheme: + ), + darkTheme: forPlatform( (ref .watch(SettingsController.provider) .maybeWhen( @@ -134,14 +142,16 @@ class const App({super.key}) extends StatelessWidget { ) ?? ThemeData.dark().colorScheme) .theme, - themeMode: ref - .watch(SettingsController.provider) - .maybeWhen( - data: (settings) => settings.theme, - orElse: () => ThemeMode.system, - ), - home: child, - ), + ), + themeMode: ref + .watch(SettingsController.provider) + .maybeWhen( + data: (settings) => settings.theme, + orElse: () => ThemeMode.system, + ), + home: child, + ); + }, child: DefaultTextHeightBehavior( textHeightBehavior: .new(leadingDistribution: .even), child: Scaffold( diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 955d8d1..0ab5990 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 IMap.empty(), + final IMap> directMessages = const .empty(), @JsonKey(name: AccountData.recentEmojiKey) final RecentEmojiData recentEmoji = const RecentEmojiData(), @@ -30,7 +30,7 @@ class const AccountData({ @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const RecentEmojiData({ - final IList recentEmoji = const IList.empty(), + final IList recentEmoji = const .empty(), }) with _$RecentEmojiData { Map toJson() => _$RecentEmojiDataToJson(this); diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 1a35d92..6fb2829 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({ - String? alias, + @JsonKey(includeIfNull: false) 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 3f1c17b..948a762 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(IList.empty()) IList allow, + @Default(ISet.empty()) ISet allow, }) = _JoinRulesContent; factory JoinRulesContent.fromJson(Map json) => @@ -21,6 +21,8 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent { @JsonSerializable() class const AllowCondition({ final String? roomId, + + @JsonKey(unknownEnumValue: AllowConditionType.unknown) required final AllowConditionType type, }) with _$AllowCondition { Map toJson() => _$AllowConditionToJson(this); @@ -32,4 +34,5 @@ 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 5ae6561..afa0e23 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, - Notifications? notifications, + @JsonKey(includeIfNull: false) Notifications? notifications, @Default(50) int ban, @Default(0) int eventsDefault, @Default(0) int invite, @@ -28,7 +28,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { @JsonSerializable() class const Notifications({ final int room = 50, - final IMap other = const IMap.empty(), + final IMap other = const .empty(), }) with _$Notifications { Map toJson() => _$NotificationsToJson(this); diff --git a/lib/models/event.dart b/lib/models/event.dart index 18ad11b..93352ca 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 IMap.empty(), + final IMap unsigned = const .empty(), final LocalContent? localContent, final String? transactionId, final String? redactedBy, @@ -27,7 +27,7 @@ class const Event({ final String? replyTo, final String? decryptionError, final String? sendError, - final IMap reactions = const IMap.empty(), + final IMap reactions = const .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 6103c7d..e4ae517 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 IList.empty(), + final IList relatedEvents = const .empty(), }) with _$EventContext { Map toJson() => _$EventContextToJson(this); diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart index 266fb01..cc09d1c 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 double? height, - @JsonKey(name: "w") final double? width, + @JsonKey(name: "h") final int? height, + @JsonKey(name: "w") final int? width, @JsonKey(name: "mimetype") final String? mimeType, @JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash, final int? size, diff --git a/lib/models/paginate.dart b/lib/models/paginate.dart index dc6eede..b7c6bf8 100644 --- a/lib/models/paginate.dart +++ b/lib/models/paginate.dart @@ -1,6 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/read_receipt.dart"; part "paginate.freezed.dart"; part "paginate.g.dart"; @@ -10,6 +11,9 @@ part "paginate.g.dart"; class const Paginate({ required final IList 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 9f45c1f..fbd3fa7 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({ - required final IList events, - final IList relatedEvents = const IList.empty(), + final IList events = const .empty(), + final IList relatedEvents = const .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 57f76c5..3896e32 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 IList.empty(), + final IList pronouns = const .empty(), }) with _$Profile { Map toJson() => _$ProfileToJson(this); diff --git a/lib/models/read_receipt.dart b/lib/models/read_receipt.dart index ab4edd8..4d62635 100644 --- a/lib/models/read_receipt.dart +++ b/lib/models/read_receipt.dart @@ -13,6 +13,11 @@ class const ReadReceipt({ required final String eventId, @EpochDateTimeConverter() required final DateTime timestamp, }) with _$ReadReceipt { + String get thread => switch (threadId) { + final threadId? when threadId.isNotEmpty => threadId, + _ => "main", + }; + Map 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 d2fbcc9..262ee73 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 IList.empty(), + final IList invite = const .empty(), @JsonKey(name: "uk.timedout.msc4491.invite_reason") final String? inviteReason, - final IList initialState = const IList.empty(), + final IList initialState = const .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 3cdbc88..b8319cd 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -8,7 +8,8 @@ part "join_room.g.dart"; @JsonSerializable() class const JoinRoomRequest({ required final String roomIdOrAlias, - final IList via = const IList.empty(), + final IList via = const .empty(), + final String? reason, /// 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 new file mode 100644 index 0000000..8a19d3e --- /dev/null +++ b/lib/models/requests/search.dart @@ -0,0 +1,21 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "search.freezed.dart"; +part "search.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const SearchRequest({ + required final String searchTerm, + final IList 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 9907439..10a9330 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 IList.empty(), + final IList userIds = const .empty(), }) with _$Mentions { Map toJson() => _$MentionsToJson(this); diff --git a/lib/models/room.dart b/lib/models/room.dart index 24b0c5e..f02c6ac 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 IMap.empty(), + final IMap timeline = const .empty(), /// [clientSticky] is an ISet of eventRowId @JsonKey(includeFromJson: false, includeToJson: false) - final ISet clientSticky = const ISet.empty(), + final ISet clientSticky = const .empty(), /// [events] is an IMap of eventRowId to event @JsonKey(fromJson: Room.eventsJsonToIMap) - final IMap events = const IMap.empty(), + final IMap events = const .empty(), final bool reset = false, @JsonKey(includeFromJson: false, includeToJson: false) @@ -34,9 +34,9 @@ class const Room({ @JsonKey(includeFromJson: false, includeToJson: false) final bool hasFetchedMembers = false, - final IMap> state = const IMap.empty(), + final IMap> state = const .empty(), - final IMap> receipts = const IMap.empty(), + final IMap> receipts = const .empty(), final bool dismissNotifications = false, final bool hasMore = true, diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 822fcda..7621650 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -1,4 +1,5 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/epoch_date_time_converter.dart"; import "package:nexus/models/lazy_load_summary.dart"; @@ -15,6 +16,8 @@ class const RoomMetadata({ final String? topic, final String? canonicalAlias, final LazyLoadSummary? lazyLoadSummary, + + final EncryptionContent? encryptionEvent, required final bool hasMemberList, @JsonKey(name: "preview_event_rowid") required final int previewEventRowID, @EpochDateTimeConverter() required final DateTime sortingTimestamp, diff --git a/lib/models/room_power_levels.dart b/lib/models/room_power_levels.dart new file mode 100644 index 0000000..028d161 --- /dev/null +++ b/lib/models/room_power_levels.dart @@ -0,0 +1,11 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/content/power_levels.dart"; + +class const RoomPowerLevels({ + required final PowerLevelsContent content, + final ISet 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 6d22f6c..05eaf43 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -1,3 +1,4 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/membership.dart"; @@ -14,6 +15,9 @@ class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, final JoinRule? joinRule, + + /// Rooms whose members may join without an invite, for restricted rooms. + final IList allowedRoomIds = const .empty(), final String? name, final Uri? avatarUrl, final String? canonicalAlias, @@ -41,6 +45,14 @@ class const RoomSummary({ /// Whether this is an invite to a direct message. @JsonKey(includeFromJson: false) final bool isDirect = false, }) with _$RoomSummary { + /// Whether the user has to knock rather than join, given the rooms they've + /// joined. Restricted knock rooms can be joined directly from an allowed room. + bool mustKnock(ISet 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 f9198ed..7ea61d9 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,6 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/show_and_send.dart"; part "settings.freezed.dart"; part "settings.g.dart"; @@ -14,7 +15,13 @@ class const Settings({ final bool showAllInHome = false, final bool showMinimizeButton = false, final bool swipeToReply = false, + final bool hideMembershipEvents = false, final RoomOrder roomOrder = .unreadFirst, + final ShowAndSend typingIndicators = .showAndSend, + + /// When not sending, read receipts are still sent privately, so that the + /// user's other devices know what has been read. + final ShowAndSend readReceipts = .showAndSend, }) with _$Settings { Map toJson() => _$SettingsToJson(this); diff --git a/lib/models/show_and_send.dart b/lib/models/show_and_send.dart new file mode 100644 index 0000000..f9663cc --- /dev/null +++ b/lib/models/show_and_send.dart @@ -0,0 +1,2 @@ +/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts. +enum ShowAndSend { showAndSend, show, neither } diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart index a26043b..4639956 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 IMap.empty(), - final IMap rooms = const IMap.empty(), - final ISet leftRooms = const ISet.empty(), - final IList invitedRooms = const IList.empty(), + final IMap> accountData = const .empty(), + final IMap rooms = const .empty(), + final ISet leftRooms = const .empty(), + final IList invitedRooms = const .empty(), final IMap>? spaceEdges, final IList? topLevelSpaces, }) with _$SyncData { diff --git a/lib/models/typing.dart b/lib/models/typing.dart new file mode 100644 index 0000000..bd0198c --- /dev/null +++ b/lib/models/typing.dart @@ -0,0 +1,14 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "typing.freezed.dart"; +part "typing.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const Typing({ + required final String roomId, + final IList 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 226da68..123ae79 100644 --- a/lib/widgets/appbar.dart +++ b/lib/widgets/appbar.dart @@ -23,23 +23,26 @@ final class const Appbar({ @override Widget build(BuildContext context, WidgetRef ref) { - Future maximize() async { - final isMaximized = await windowManager.isMaximized(); + final isDesktop = !(Platform.isAndroid || Platform.isIOS); - if (isMaximized) { - return windowManager.unmaximize(); + void maximize() { + final window = WindowManager.instance.getCurrent(); + if (window == null) return; + + if (window.isMaximized) { + return window.unmaximize(); } - return windowManager.maximize(); + return window.maximize(); } return GestureDetector( onPanStart: ref .watch(SettingsController.provider) .whenOrNull( - data: (settings) => settings.linuxMobileMode + data: (settings) => !isDesktop || settings.linuxMobileMode ? null - : (_) => windowManager.startDragging(), + : (_) => WindowManager.instance.getCurrent()?.startDragging(), ), child: AppBar( leading: leading == null ? null : InkWell(onTap: onTap, child: leading), @@ -50,10 +53,12 @@ final class const Appbar({ onTap: onTap, child: IgnorePointer(child: title), ), - flexibleSpace: GestureDetector(onDoubleTap: maximize), + flexibleSpace: GestureDetector( + onDoubleTap: isDesktop ? maximize : null, + ), actions: [ ...actions, - if (windowControls && !(Platform.isAndroid || Platform.isIOS)) ...[ + if (windowControls && isDesktop) ...[ if (ref .watch(SettingsController.provider) .whenOrNull( @@ -62,7 +67,8 @@ final class const Appbar({ false) IconButton( tooltip: "Minimize window", - onPressed: windowManager.minimize, + onPressed: () => + WindowManager.instance.getCurrent()?.minimize(), icon: const Icon(Icons.minimize), ), if (!Platform.isLinux) diff --git a/lib/widgets/avatar_ring.dart b/lib/widgets/avatar_ring.dart new file mode 100644 index 0000000..f410978 --- /dev/null +++ b/lib/widgets/avatar_ring.dart @@ -0,0 +1,39 @@ +import "package:material_ui/material_ui.dart"; + +class const AvatarRing({ + required final String tooltip, + required final VoidCallback onTap, + required final Widget child, + final double dimension = 24, + super.key, +}) extends StatelessWidget { + static const ringWidth = 1.5; + + @override + Widget build(BuildContext context) => Tooltip( + message: tooltip, + child: Container( + padding: .all(ringWidth), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: .circle, + ), + child: ClipOval( + child: SizedBox.square( + dimension: dimension, + child: Stack( + fit: .expand, + children: [ + child, + // Over the child, so the ripple shows on top of avatars + Material( + type: .transparency, + child: InkWell(onTap: onTap), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/bouncing_dots.dart b/lib/widgets/bouncing_dots.dart new file mode 100644 index 0000000..37f655c --- /dev/null +++ b/lib/widgets/bouncing_dots.dart @@ -0,0 +1,41 @@ +import "dart:math"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// Three dots that bounce one after another, in a loop. +class const BouncingDots({required final Color color, super.key}) + extends HookWidget { + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const .new(milliseconds: 1200), + ); + useEffect(() { + controller.repeat(); + return null; + }, []); + final progress = useAnimation(controller); + + return Row( + mainAxisSize: .min, + spacing: 3, + children: [0, 1, 2].map((index) { + final phase = (progress - index * 0.15) % 1; + final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0; + + return Transform.translate( + offset: .new(0, -3 * bounce), + child: Opacity( + opacity: 0.4 + 0.6 * bounce, + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: .circle), + ), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 9b596c6..2648347 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/attachment.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/image_picker.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:image_picker/image_picker.dart"; @@ -94,6 +96,44 @@ class const Composer( return null; }, [relationType, relatedEvent == null]); + final sinceTypingSent = useMemoized(Stopwatch.new); + + useEffect(() { + final client = ref.read(ClientController.provider.notifier); + + void stopTyping() { + if (!sinceTypingSent.isRunning) return; + sinceTypingSent + ..stop() + ..reset(); + client.setTyping(roomId, .zero).ignore(); + } + + void onChanged() { + final shouldSend = + ref.read(SettingsController.provider).value?.typingIndicators == + .showAndSend; + + if (!shouldSend || controller.text.isEmpty) return stopTyping(); + + if (sinceTypingSent.isRunning && + sinceTypingSent.elapsed < const .new(seconds: 5)) { + return; + } + + sinceTypingSent + ..reset() + ..start(); + client.setTyping(roomId, const .new(seconds: 10)).ignore(); + } + + controller.addListener(onChanged); + return () { + controller.removeListener(onChanged); + stopTyping(); + }; + }, [roomId]); + final attachment = ref.watch(AttachmentController.provider(roomId)); Future attach(Future pick) async { diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart new file mode 100644 index 0000000..2a30c4b --- /dev/null +++ b/lib/widgets/composer/typing_indicator.dart @@ -0,0 +1,113 @@ +import "package:collection/collection.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_typing.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/bouncing_dots.dart"; + +class const TypingIndicator(final String roomId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final typing = ref.watch(RoomTypingController.provider(roomId)); + + // Past three people, only the first two are named + final named = typing.length > 3 ? 2 : typing.length; + final subjects = [ + ...typing + .take(named) + .map( + (typingUserId) => WidgetSpan( + alignment: .baseline, + baseline: .alphabetic, + child: Builder( + builder: (context) => InkWell( + onTap: () => + context.showUserPopover(typingUserId, roomId: roomId), + child: Text( + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: .bold, + ), + ), + ), + ), + ), + ), + if (typing.length > named) + TextSpan(text: "${typing.length - named} others"), + ]; + + final spans = [ + ...subjects.expandIndexed( + (index, subject) => [ + if (index > 0) + TextSpan( + text: index < subjects.length - 1 + ? ", " + : subjects.length > 2 + ? ", and " + : " and ", + ), + subject, + ], + ), + TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."), + ]; + + return AnimatedSwitcher( + duration: Durations.medium2, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + alignment: .bottomLeft, + child: child, + ), + ), + child: typing.isEmpty + ? const SizedBox.shrink() + : Align( + alignment: .bottomLeft, + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + elevation: 1, + shape: const StadiumBorder(), + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + BouncingDots(color: theme.colorScheme.onSurfaceVariant), + Flexible( + child: Text.rich( + TextSpan(children: spans), + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 94aec91..6ae2221 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 case MembershipContent membership) { - context.showUserPopover(membership, mention, roomId: roomId); + if (data is MembershipContent) { + context.showUserPopover(mention, roomId: roomId); } else if (data case RoomSummary summary) { final eventRowId = linkInfo?.eventId == null ? null diff --git a/lib/widgets/knock_sheet.dart b/lib/widgets/knock_sheet.dart new file mode 100644 index 0000000..9c6bdcb --- /dev/null +++ b/lib/widgets/knock_sheet.dart @@ -0,0 +1,51 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Asks the moderators of a room to let the user in, with an optional reason. +/// +/// Pops with true once the request has been sent. +class const KnockSheet( + final String roomId, { + required final String name, + final IList 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 50b612a..0279eca 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -12,18 +12,17 @@ class const MessageAvatar( super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( - AuthorController.provider(event), - )) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTap: () => - context.showUserPopover(value, event.sender, roomId: event.roomId), - child: AvatarOrHash( - value.avatarUrl, - value.displayName ?? event.sender.localpart, - dimension: height, - ), - ), - _ => AvatarOrHash(null, event.sender.localpart, dimension: height), - }; + Widget build(BuildContext context, WidgetRef ref) => + switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + dimension: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, dimension: height), + }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index 593e755..e8e4791 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -21,12 +21,8 @@ class const MessageDisplayname( AsyncData(:final MembershipContent? value) || AsyncLoading(:final value) || AsyncError(:final value) => InkWell( - onTap: clickable && value != null - ? () => context.showUserPopover( - value, - event.sender, - roomId: event.roomId, - ) + onTap: clickable + ? () => context.showUserPopover(event.sender, roomId: event.roomId) : null, child: switch ([ Text( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 85e9fcc..731270d 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -25,6 +25,7 @@ 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]; @@ -36,7 +37,7 @@ class const MemberList(final String roomId, {super.key}) body: Column( children: [ Padding( - padding: .symmetric(vertical: 8), + padding: .all(8), child: M3EToggleButtonGroup( selectedIndex: statusIndex.value, onSelectedIndexChanged: (index) => @@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key}) itemBuilder: (context, index) => switch (members[index].content) { MembershipContent( - :final avatarUrl, - :final displayName, - ) && - final content => + :final avatarUrl, + :final displayName, + ) => Builder( builder: (context) => ListTile( onTap: () => context.showUserPopover( - content, members[index].stateKey!, roomId: roomId, ), diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 42c08d6..969dcd3 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, + width: info?.width?.toDouble(), fit: BoxFit.fitWidth, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null @@ -40,7 +40,7 @@ final class const MessageImage( child: BlurHash(hash: blurHash), ) : SizedBox( - width: info!.width, + width: info!.width!.toDouble(), child: BlurHash(hash: blurHash), ), _ => Loading(), diff --git a/lib/widgets/mutual_rooms_sheet.dart b/lib/widgets/mutual_rooms_sheet.dart new file mode 100644 index 0000000..68b90e5 --- /dev/null +++ b/lib/widgets/mutual_rooms_sheet.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/mutual_rooms.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// The rooms the user shares with [userId]. +class const MutualRoomsSheet(final String userId, {super.key}) + extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text("Mutual Rooms"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: [ + ref + .watch(MutualRoomsController.provider(userId)) + .betterWhen( + data: (roomIds) { + final rooms = ref.watch( + RoomsController.provider.select( + (allRooms) => roomIds + .map((roomId) => allRooms[roomId]?.metadata) + .nonNulls + .toIList(), + ), + ); + + return rooms.isEmpty + ? Text("No mutual rooms") + : Column( + children: rooms + .map( + (room) => ListTile( + leading: AvatarOrHash( + room.avatar, + room.name ?? "Unnamed Room", + fallback: Icon(Icons.numbers), + ), + title: Text( + room.name ?? "Unnamed Room", + overflow: .ellipsis, + ), + onTap: () async { + final container = ProviderScope.containerOf( + context, + ); + // Closes the user popover under this too + Navigator.of(context) + .popUntil((route) => route.isFirst); + await container.focusRoom(room.id); + container + .read( + SidebarPageController.provider.notifier, + ) + .set(.rooms); + }, + ), + ) + .toList(), + ); + }, + ), + ], + ); +} diff --git a/lib/widgets/pages/settings.dart b/lib/widgets/pages/settings.dart index 6bc298c..c205d3e 100644 --- a/lib/widgets/pages/settings.dart +++ b/lib/widgets/pages/settings.dart @@ -1,247 +1,19 @@ -import "package:collection/collection.dart"; -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:material_ui/material_ui.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:m3e_card_list/m3e_card_list.dart"; -import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/settings_sections.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_about_sheet.dart"; -import "package:nexus/widgets/pages/settings_category.dart"; -import "package:nexus/widgets/divider_text.dart"; -import "package:nexus/widgets/highlight_wrapper.dart"; -import "package:super_sliver_list/super_sliver_list.dart"; +import "package:nexus/widgets/settings/settings_layout.dart"; class const SettingsPage({super.key}) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( - builder: (_, constraints) => HookBuilder( - builder: (context) { - final categoriesArePages = constraints.maxWidth < 550; - - final selected = useState(0); - - final highlightedMatch = useState<(int, int)?>(null); - final listController = useRef(ListController()); - final scrollController = useScrollController(); - - final searchBar = SearchAnchor.bar( - barHintText: "Search...", - isFullScreen: categoriesArePages, - suggestionsBuilder: (suggestionsContext, controller) async { - final categories = await ref.read( - SettingsSectionsController.provider.future, - ); - final query = controller.text.toLowerCase(); - - final matches = categories.values.flattenedToList - .asMap() - .entries - .expand( - (categoryEntry) => categoryEntry.value.settings - .asMap() - .entries - .where( - (settingEntry) => - settingEntry.value.title.toLowerCase().contains( - query, - ) || - settingEntry.value.description - .toLowerCase() - .contains(query), - ) - .map( - (settingEntry) => ( - (categoryEntry.key, settingEntry.key), - settingEntry.value, - ), - ), - ) - .toIList(); - - return matches.map( - (match) => ListTile( - onTap: () async { - if (context.mounted) Navigator.of(suggestionsContext).pop(); - controller.text = ""; - - if (categoriesArePages) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => SettingsCategoryPage( - match.$1.$1, - initialHighlight: match.$1.$2, - ), - ), - ); - } else { - selected.value = match.$1.$1; - listController.value.animateToItem( - index: match.$1.$2, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => Durations.extralong1, - curve: (_) => Curves.easeInOut, - ); - highlightedMatch.value = match.$1; - await Future.delayed(Durations.extralong4, () { - if (highlightedMatch.value == match.$1) { - highlightedMatch.value = null; - } - }); - } - }, - leading: Icon(match.$2.icon), - title: Text(match.$2.title), - subtitle: Text(match.$2.description), - ), - ); - }, - ); - - final settingsContent = Scaffold( - appBar: AppBar( - title: Text("Settings"), - actionsPadding: .symmetric(horizontal: 12), - actions: [ - IconButton( - onPressed: () => context.showAboutSheet(ref), - icon: Icon(Icons.info_outline), - ), - ], - ), - body: ref - .watch(SettingsSectionsController.provider) - .betterWhen( - data: (sections) => categoriesArePages - ? CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(12).copyWith(bottom: 8), - child: searchBar, - ), - ), - ...sections - .mapTo( - (section, categories) => [ - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 16, - ).copyWith(bottom: 4), - child: DividerText(section), - ), - ), - SliverM3ECardList( - padding: .symmetric( - horizontal: 4, - vertical: 8, - ), - margin: .symmetric(horizontal: 12), - color: Theme.of(context) - .colorScheme - .primaryContainer, - itemCount: categories.length, - onTap: (index) => Navigator.of(context) - .push( - MaterialPageRoute( - builder: (context) => - SettingsCategoryPage( - sections - .values - .flattenedToList - .indexOf( - categories[index], - ), - ), - ), - ), - itemBuilder: (context, index) => ListTile( - leading: Icon(categories[index].icon), - title: Text(categories[index].title), - ), - ), - ], - ) - .flattened, - ], - ) - : Row( - children: [ - MaterialUiCompatibilityBridge( - child: NavigationRailM3E( - type: .alwaysExpand, - trailing: searchBar, - scrollable: true, - sections: sections - .mapTo( - ( - categoryGroup, - categories, - ) => NavigationRailM3ESection( - header: DividerText(categoryGroup), - destinations: categories - .map( - (category) => - NavigationRailM3EDestination( - icon: Icon(category.icon), - label: category.title, - ), - ) - .toList(), - ), - ) - .toList(), - selectedIndex: selected.value, - onDestinationSelected: (value) => - selected.value = value, - ), - ), - VerticalDivider(), - Expanded( - child: SuperListView( - listController: listController.value, - controller: scrollController, - padding: .symmetric(vertical: 12), - children: sections - .values - .flattenedToList[selected.value] - .settings - .mapIndexed( - (index, setting) => Padding( - padding: .only(bottom: 4), - child: HighlightWrapper( - setting.builder( - setting.title, - setting.description, - setting.icon, - ), - isHighlighted: - highlightedMatch.value == - (selected.value, index), - ), - ), - ) - .toList(), - ), - ), - ], - ), - ), - ); - - return constraints.maxWidth < 650 - ? settingsContent - : Dialog( - constraints: .loose(Size(900, 600)), - child: ClipRRect( - borderRadius: BorderRadiusGeometry.circular(12), - child: settingsContent, - ), - ); - }, - ), + Widget build(BuildContext context, WidgetRef ref) => SettingsLayout( + title: "Settings", + sectionsProvider: SettingsSectionsController.provider, + actions: [ + IconButton( + onPressed: () => context.showAboutSheet(ref), + icon: Icon(Icons.info_outline), + ), + ], ); } diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart index 2398f7f..69513b8 100644 --- a/lib/widgets/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -4,13 +4,19 @@ import "package:collection/collection.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/settings_sections.dart"; +import "package:hooks_riverpod/misc.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:nexus/models/settings_category.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const SettingsCategoryPage( final int index, { + required final ProviderListenable< + AsyncValue>> + > + sectionsProvider, final int? initialHighlight, super.key, }) extends HookConsumerWidget { @@ -45,8 +51,9 @@ class const SettingsCategoryPage( }, []); return ref - .watch(SettingsSectionsController.provider) + .watch(sectionsProvider) .betterWhen( + skipLoadingOnReload: true, data: (sections) => Scaffold( appBar: AppBar( title: Text(sections.values.flattenedToList[index].title), diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index 598687c..3e254b0 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -28,6 +28,7 @@ class const AudioPlayer( final audio = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + if (!context.mounted) return; await player.open(Media(audio.path), play: false); if (context.mounted) loaded.value = true; }); diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 557e7c1..ef56024 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -30,6 +30,7 @@ class const VideoPlayer( final video = await ref .read(ClientController.provider.notifier) .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + if (!context.mounted) return; await player.open(Media(video.path), play: false); if (context.mounted) loaded.value = true; }); diff --git a/lib/widgets/read_receipts_row.dart b/lib/widgets/read_receipts_row.dart new file mode 100644 index 0000000..e3f206f --- /dev/null +++ b/lib/widgets/read_receipts_row.dart @@ -0,0 +1,112 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/avatar_ring.dart"; +import "package:nexus/widgets/read_receipts_sheet.dart"; + +class const ReadReceiptsRow( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + static const avatarSize = 24.0; + static const overlap = 6.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final receipts = ref.watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ); + + return AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topRight, + child: receipts.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + // Clear of the scrollbar + padding: .only(right: 16, bottom: 2), + child: LayoutBuilder( + builder: (context, constraints) { + const width = avatarSize + AvatarRing.ringWidth * 2; + final fits = + (constraints.maxWidth - width) ~/ (width - overlap) + 1; + + // If they don't all fit, the last spot shows how many more there are + final shown = receipts.length <= fits + ? receipts.length + : fits - 1; + + return Align( + alignment: .centerRight, + child: Wrap( + spacing: -overlap, + children: [ + ...receipts + .take(shown) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: chat.roomId, + userId: receipt.userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? + receipt.userId.localpart; + + return AvatarRing( + tooltip: name, + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + child: AvatarOrHash( + member?.avatarUrl, + name, + dimension: avatarSize, + ), + ); + }, + ), + ), + if (receipts.length > shown) + AvatarRing( + tooltip: "Show everyone who has read up to here", + onTap: () => context.showAdaptiveSheet( + (_) => ReadReceiptsSheet(eventId, chat: chat), + ), + child: ColoredBox( + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: Text( + "+${receipts.length - shown}", + style: theme.textTheme.labelSmall, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/read_receipts_sheet.dart b/lib/widgets/read_receipts_sheet.dart new file mode 100644 index 0000000..aabdfb3 --- /dev/null +++ b/lib/widgets/read_receipts_sheet.dart @@ -0,0 +1,54 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/read_receipts.dart"; +import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Everyone who has read up to an event, kept up to date while open. +class const ReadReceiptsSheet( + final String eventId, { + required final ChatParams chat, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => SheetLayout( + title: Text("Read by"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")), + ], + children: ref + .watch( + ReadReceiptsController.provider(chat) + .select((receipts) => receipts[eventId] ?? const .empty()), + ) + .map( + (receipt) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new(roomId: chat.roomId, userId: receipt.userId), + ), + ) + .value; + final name = member?.displayName ?? receipt.userId.localpart; + + return ListTile( + leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40), + title: Text(name, overflow: .ellipsis), + subtitle: Text(receipt.userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + receipt.userId, + roomId: chat.roomId, + ), + ); + }, + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 3ec3d4a..47750fa 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,6 +43,10 @@ class const EventRenderer( /// If set, events can be swiped, calling this final VoidCallback? onSwipe, + + /// If set, shows who in this chat has read up to this event, e.g. in the + /// timeline. + final ChatParams? readReceiptsChat, super.key, }) extends HookConsumerWidget { @override @@ -78,10 +82,24 @@ class const EventRenderer( if (event == null) return SizedBox.shrink(); + final hideMembershipEvents = (ref + .watch(SettingsController.provider) + .maybeWhen( + orElse: () => false, + data: (settings) => settings.hideMembershipEvents, + )); + + // Knocks and their outcomes stay visible, as moderators need to act on them + if (hideMembershipEvents && + event.content is MembershipContent && + ![event.content, event.previousContent].any( + (content) => content is MembershipContent && content.status == .knock, + )) { + return SizedBox.shrink(); + } + final isGrouped = - previousEvent?.content is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != "m.replace" && + (previousEvent?.isShownAsMessage ?? false) && event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; @@ -94,7 +112,26 @@ class const EventRenderer( TextSpan(text: text), ]); - final child = event.redactedBy != null || event.relationType == "m.replace" + final child = event.isShownAsMessage + ? MessageRenderer( + event, + onTapReply: jumpToEvent == null + ? null + : () async { + final replyEvent = await ref.read( + EventController.provider( + .new(roomId: roomId, eventId: event.replyTo!), + ).future, + ); + if (replyEvent != null) { + await jumpToEvent!(replyEvent.rowId); + } + }, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, + ) + : event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { Content(:final parseError?, :final stackTrace) => Row( @@ -106,27 +143,6 @@ class const EventRenderer( ], ), - MessageContent() || - EncryptedContent() || - StickerContent() => MessageRenderer( - event, - onTapReply: jumpToEvent == null - ? null - : () async { - final replyEvent = await ref.read( - EventController.provider( - .new(roomId: roomId, eventId: event.replyTo!), - ).future, - ); - if (replyEvent != null) { - await jumpToEvent!(replyEvent.rowId); - } - }, - isGrouped: isGrouped, - maxLines: maxLines, - textOnly: textOnly, - ), - MembershipContent content => switch (event.previousContent) { MembershipContent(:final status) => status == content.status @@ -268,6 +284,10 @@ class const EventRenderer( ...[ if (child is! MessageRenderer) ReactionRow(event), + // Outside the message itself, so it doesn't move when swiped + if (readReceiptsChat case final chat? when event.isShownAsMessage) + ReadReceiptsRow(event.eventId, chat: chat), + if (event.sendError != null && event.sendError != "not sent") Padding( padding: .only(bottom: 4), diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index c57200c..b1811f0 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -19,6 +19,11 @@ class const MembershipRenderer( "Make sure to only pass membership events to MembershipRenderer", ); + final previous = switch (event.previousContent) { + MembershipContent(:final status) => status, + _ => null, + }; + return switch (event.content) { MembershipContent content => GenericEventRenderer( Icons.people, @@ -28,7 +33,6 @@ class const MembershipRenderer( alignment: .middle, child: InkWell( onTap: () => context.showUserPopover( - content, event.stateKey!, roomId: event.roomId, ), @@ -43,9 +47,10 @@ class const MembershipRenderer( TextSpan( text: "${switch (content.status) { - .invite => "was invited to", + .invite => previous == .knock ? "was accepted into" : "was invited to", .join => "joined", - .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + .leave when previous == .knock => event.sender == event.stateKey ? "retracted their knock on" : "had their knock denied in", + .leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"), .ban => "was banned from", .knock => "asked to join", }} the room${event.sender == event.stateKey ? "" : " by"}", diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index e284bc5..248661e 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -16,6 +16,7 @@ final class const RoomAppbar({ required final void Function() onOpenDrawer, final void Function(BuildContext context)? onOpenMemberList, final void Function()? onOpenPinnedMessagesList, + final void Function()? onOpenSearch, super.key, }) extends ConsumerWidget implements PreferredSizeWidget { @override @@ -61,6 +62,11 @@ final class const RoomAppbar({ room == null ? [] : [ + IconButton( + onPressed: onOpenSearch?.call, + icon: Icon(Icons.search), + tooltip: "Search this room", + ), IconButton( onPressed: onOpenPinnedMessagesList?.call, icon: Icon(Icons.push_pin), diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 8a4c071..c949344 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -1,5 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_chat.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/event.dart"; @@ -7,12 +8,15 @@ import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ - required final String roomId, + required final ChatParams chat, required final ChatScroll scroll, required final JumpToEvent jumpToEvent, required final IList Function(Event) getEventOptions, required final int? highlightedEvent, required final double composerHeight, + + /// The typing indicator's height, or zero while it's hidden. + required final double typingIndicatorHeight, final void Function(int rowId)? onSwipe, super.key, }) extends StatelessWidget { @@ -24,10 +28,11 @@ class const ChatTimeline({ EventRenderer( eventRowId, previousEventRowId: previousEventRowId, - roomId: roomId, + roomId: chat.roomId, jumpToEvent: jumpToEvent, getEventOptions: getEventOptions, onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), + readReceiptsChat: chat, ), key: key, isHighlighted: highlightedEvent == eventRowId, @@ -40,7 +45,22 @@ class const ChatTimeline({ keyboardDismissBehavior: .onDrag, controller: scroll.scrollController, slivers: [ - SliverToBoxAdapter(child: SizedBox(height: composerHeight)), + SliverToBoxAdapter( + child: Column( + children: [ + // Makes room for the typing indicator, with a small gap above it, + // so it doesn't cover messages + AnimatedContainer( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + height: typingIndicatorHeight == 0 + ? 0 + : typingIndicatorHeight + 4, + ), + SizedBox(height: composerHeight), + ], + ), + ), SuperSliverList.builder( itemCount: scroll.liveRows.length, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 155e90e..e7fd6e0 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -15,11 +15,13 @@ import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; +import "package:nexus/widgets/composer/typing_indicator.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/widgets/search_drawer.dart"; import "package:nexus/widgets/sync_status_banner.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; @@ -45,6 +47,7 @@ final class const RoomChat({ final highlightedEvent = useState(null); final composerSize = useState(64); + final typingIndicatorSize = useState(0); final userId = ref.watch(ClientStateController.provider)?.userId; final memberListOpened = ref @@ -82,10 +85,11 @@ final class const RoomChat({ ), ); - final controllerProvider = RoomChatController.provider(( + final ChatParams chat = ( roomId: roomId, contextualEvent: contextualEvent.value, - )); + ); + final controllerProvider = RoomChatController.provider(chat); final notifier = ref.watch(controllerProvider.notifier); final client = ref.read(ClientController.provider.notifier); @@ -198,115 +202,148 @@ final class const RoomChat({ ); return Scaffold( - endDrawer: PinnedEventsDrawer( + endDrawerEnableOpenDragGesture: false, + endDrawer: SearchDrawer( roomId, getEventOptions: getEventOptions, jumpToEvent: jumpToEvent, ), body: Builder( - builder: (middleContext) => Scaffold( - endDrawer: showMembersByDefault ? null : MemberList(roomId), - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: Scaffold.of(context).openDrawer, - onOpenMemberList: (thisContext) { - ref - .read(MemberListOpenedController.provider.notifier) - .set(!memberListOpened); - Scaffold.of(thisContext).openEndDrawer(); - }, - onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer, + builder: (searchContext) => Scaffold( + endDrawerEnableOpenDragGesture: false, + endDrawer: PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + jumpToEvent: jumpToEvent, ), - body: Column( - children: [ - const SyncStatusBanner(), - Expanded( - child: Row( - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncLoading(value: _?) || - AsyncData(value: _) => ChatTimeline( - scroll: scroll, - roomId: roomId, - jumpToEvent: jumpToEvent, - getEventOptions: getEventOptions, - onSwipe: swipeToReply && canReply - ? (rowId) => onRelation(rowId, .reply) - : null, - highlightedEvent: highlightedEvent.value, - composerHeight: composerSize.value, - ), - AsyncLoading _ => Loading(), - AsyncError(:final error, :final stackTrace) => - ErrorDialog(error, stackTrace), - }, - ), - ), - Positioned( - right: 16, - bottom: composerSize.value, - child: IgnorePointer( - ignoring: scroll.atBottom, - child: AnimatedOpacity( - opacity: scroll.atBottom ? 0 : 1, - duration: Durations.short4, - child: FloatingActionButton.small( - onPressed: scroll.jumpToBottom, - child: const Icon(Icons.keyboard_arrow_down), - ), - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => - composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - ( - text, { - required shouldMention, - required tags, - }) => notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relatedEventRowId: relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () { - relatedEvent.value = null; - composerNode.requestFocus(); - }, - ), - ), - ), - ], - ), - ), - - if (memberListOpened && showMembersByDefault) - MemberList(roomId), - ], - ), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + ref + .read(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: Scaffold.of(middleContext) + .openEndDrawer, + onOpenSearch: Scaffold.of(searchContext).openEndDrawer, ), - ], + body: Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + chat: chat, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + onSwipe: swipeToReply && canReply + ? (rowId) => onRelation(rowId, .reply) + : null, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + typingIndicatorHeight: + typingIndicatorSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError( + :final error, + :final stackTrace, + ) => + ErrorDialog(error, stackTrace), + }, + ), + ), + AnimatedPositioned( + duration: Durations.short4, + bottom: composerSize.value, + left: 12, + // Leaves room for the jump to bottom button when it's shown + right: scroll.atBottom ? 12 : 72, + child: MeasureSize( + onChange: (size) => + typingIndicatorSize.value = size.height, + child: TypingIndicator(roomId), + ), + ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: Durations.short4, + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon( + Icons.keyboard_arrow_down, + ), + ), + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => + composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relatedEventRowId: + relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + ), + ), + ], + ), + ), + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), + ], + ), + ), + ], + ), + ), ), ), ), diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index bc8e9ee..38ad847 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -7,7 +7,9 @@ import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/room_settings_sections.dart"; import "package:nexus/widgets/adaptive_menu_button.dart"; +import "package:nexus/widgets/settings/settings_layout.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/models/room.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -15,7 +17,7 @@ import "package:nexus/main.dart"; final class const RoomMenuButton( final Room? room, { - final IList children = const IList.empty(), + final IList children = const .empty(), super.key, }) extends ConsumerWidget { @override @@ -122,6 +124,21 @@ final class const RoomMenuButton( title: Text("Invite"), ), ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (_) => SettingsLayout( + title: "Room Settings", + sectionsProvider: RoomSettingsSectionsController.provider( + room!.metadata!.id, + ), + ), + ), + child: ListTile( + leading: Icon(Icons.settings), + title: Text("Settings"), + ), + ), PopupMenuItem( onTap: () => context.showLeaveRoomSheet( room!.metadata!.id, diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index 60c5baa..dd7bc9b 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -2,12 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/invites.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/linkified_text.dart"; import "package:nexus/models/room_summary.dart"; @@ -15,11 +19,17 @@ class const RoomSummarySheet( final RoomSummary summary, { final IList? via, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; final isInvite = summary.membership == .invite; + final isKnocking = summary.membership == .knock; + final mustKnock = summary.mustKnock( + ref.watch( + RoomsController.provider.select((rooms) => rooms.keys.toISet()), + ), + ); final colorScheme = Theme.of(context).colorScheme; return SheetLayout( @@ -83,7 +93,35 @@ class const RoomSummarySheet( }, child: Text("Accept"), ), - ] else if (via != null) + ] else if (isKnocking) + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + + container + .read(ClientController.provider.notifier) + .leaveRoom(summary.roomId) + .onError(showError); + }, + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text("Retract knock"), + ) + else if (via != null && mustKnock) + TextButton( + onPressed: () async { + if (await context.showAdaptiveSheet( + (_) => + KnockSheet(summary.roomId, name: name, via: via!), + ) == + true && + context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text("Knock"), + ) + else if (via != null) TextButton( onPressed: () { context.joinRoom( @@ -139,6 +177,17 @@ class const RoomSummarySheet( subtitle: LinkifiedText(summary.topic!), leading: Icon(Icons.info), ), + if (isKnocking) + ListTile( + title: Text("You've knocked"), + subtitle: Text("Waiting for a moderator to let you in"), + leading: Icon(Icons.hourglass_top), + ) + else if (mustKnock) + ListTile( + title: Text("Knock to join"), + leading: Icon(Icons.door_front_door), + ), if (summary.encryption != null) ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), if (summary.worldReadable == true) diff --git a/lib/widgets/search_drawer.dart b/lib/widgets/search_drawer.dart new file mode 100644 index 0000000..03418df --- /dev/null +++ b/lib/widgets/search_drawer.dart @@ -0,0 +1,147 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/event_search.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +final class const SearchDrawer( + final String roomId, { + required final IList 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 new file mode 100644 index 0000000..2603fd5 --- /dev/null +++ b/lib/widgets/settings/add_users_power_level_sheet.dart @@ -0,0 +1,112 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/members.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses users of [roomId] to give a power level of their own. +class const AddUsersPowerLevelSheet({ + required final String roomId, + + /// Users who already have a level, or can't be given one, so aren't suggested. + required final ISet 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 new file mode 100644 index 0000000..d517f98 --- /dev/null +++ b/lib/widgets/settings/aliases_sheet.dart @@ -0,0 +1,78 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses which of a room's [aliases] it publishes, and which one of those is canonical. +class const AliasesSheet({ + required final ISet 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 new file mode 100644 index 0000000..6ba42fb --- /dev/null +++ b/lib/widgets/settings/allowed_rooms_sheet.dart @@ -0,0 +1,138 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/settings/search_chooser.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses the rooms whose members can join [roomId] without an invite. +class const AllowedRoomsSheet({ + required final String roomId, + required final ISet 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 d802d7b..b31182f 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -1,6 +1,9 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/widgets/settings/radio_sheet.dart"; final class const DialogListTile({ @@ -8,32 +11,44 @@ final class const DialogListTile({ required final Icon icon, required final String title, required final List options, - required final void Function(T value)? onChanged, + + /// While a returned future runs, the tile is disabled. + required final FutureOr Function(T value)? onChanged, required final String Function(T option) getName, final Widget? subtitle, super.key, -}) extends ConsumerWidget { +}) extends HookWidget { @override - Widget build(BuildContext context, WidgetRef ref) => ListTile( - enabled: onChanged != null, - onTap: () => context.showAdaptiveSheet( - (context) => RadioSheet( - title: title, - getName: getName, - onChanged: onChanged, - value: value, - options: options, + 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, + ), ), - ), - title: Text(title), - subtitle: subtitle, - leading: icon, - trailing: Chip( - label: Text( - value == null ? "None" : getName(value as T), - overflow: TextOverflow.ellipsis, - style: onChanged == null ? .new(color: Colors.grey) : null, + title: Text(title), + subtitle: subtitle, + leading: icon, + trailing: ConstrainedBox( + constraints: const .new(maxWidth: 160), + child: Chip( + label: Text( + value is T ? getName(value as T) : "None", + overflow: TextOverflow.ellipsis, + style: onChanged == null || saving.active + ? .new(color: Colors.grey) + : null, + ), + ), ), - ), - ); + ); + } } diff --git a/lib/widgets/settings/local_aliases_sheet.dart b/lib/widgets/settings/local_aliases_sheet.dart new file mode 100644 index 0000000..def7db9 --- /dev/null +++ b/lib/widgets/settings/local_aliases_sheet.dart @@ -0,0 +1,107 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/local_aliases.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/gomuks_exception.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +class const LocalAliasesSheet( + final String roomId, { + + /// Called after an alias is deleted, e.g. to stop publishing it. + final Future 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 new file mode 100644 index 0000000..9d5e457 --- /dev/null +++ b/lib/widgets/settings/power_level_entry_sheet.dart @@ -0,0 +1,55 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Gives something, like an event type, its own power level. +class const PowerLevelEntrySheet({ + required final String title, + + /// The label of the field for what's given the power level. + required final String keyLabel, + required final String keyHint, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + required final void Function(String key, int level) onSave, + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final keyController = useTextEditingController(); + final levelController = useTextEditingController(text: "0"); + final key = useValueListenable(keyController).text.trim(); + final level = PowerLevelField.levelFrom( + useValueListenable(levelController).text, + ownLevel, + ); + final valid = key.isNotEmpty && level != null; + + void save() { + Navigator.of(context).pop(); + onSave(key, level!); + } + + return SheetLayout( + title: Text(title), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: keyController, + autofocus: true, + decoration: .new(labelText: keyLabel, hintText: keyHint), + ), + PowerLevelField( + controller: levelController, + ownLevel: ownLevel, + onSubmitted: valid ? save : null, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/power_level_field.dart b/lib/widgets/settings/power_level_field.dart new file mode 100644 index 0000000..fe539bf --- /dev/null +++ b/lib/widgets/settings/power_level_field.dart @@ -0,0 +1,48 @@ +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// A field for a power level, which can't be above the user's own. +class const PowerLevelField({ + required final TextEditingController controller, + + /// The user's own power level, which they can't set levels above. + required final double ownLevel, + final VoidCallback? onSubmitted, + final bool autofocus = false, + super.key, +}) extends HookWidget { + /// The furthest from zero Matrix allows integers to be. + static const maxMagnitude = (2 ^ 53) - 1; + + /// The level in [text], if it's one someone with [ownLevel] can set. + static int? levelFrom(String text, double ownLevel) => switch (int.tryParse( + text, + )) { + final level? when level.abs() <= maxMagnitude && level <= ownLevel => level, + _ => null, + }; + + @override + Widget build(BuildContext context) { + final text = useValueListenable(controller).text; + + return TextField( + controller: controller, + autofocus: autofocus, + keyboardType: .numberWithOptions(signed: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))], + onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(), + decoration: .new( + labelText: "Power level", + errorText: switch (int.tryParse(text)) { + _ when text.isEmpty || text == "-" => null, + final level? when level.abs() <= maxMagnitude => + level > ownLevel ? "Can't be above your own power level" : null, + // Including ones too far from zero to even parse + _ => "Must be from -$maxMagnitude to $maxMagnitude", + }, + ), + ); + } +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart new file mode 100644 index 0000000..5b7d4e6 --- /dev/null +++ b/lib/widgets/settings/power_level_tile.dart @@ -0,0 +1,120 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/settings/power_level_field.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Sets the power level needed for something. +class const PowerLevelTile({ + required final Widget leading, + required final String title, + required final String? description, + required final int value, + + /// The user's own power level, which they can't set or change levels above. + required final double ownLevel, + + /// Disables the tile if null. + required final void Function(int value)? onChanged, + + /// Shows a button to remove the level if given, disabled along with the tile. + final VoidCallback? onRemoved, + + /// If given, asked before removing the level. + final String? removeConfirmation, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final enabled = onChanged != null && value <= ownLevel; + + Future 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 051b67a..9f9512e 100644 --- a/lib/widgets/settings/radio_sheet.dart +++ b/lib/widgets/settings/radio_sheet.dart @@ -12,7 +12,8 @@ final class const RadioSheet({ }) extends HookWidget { @override Widget build(BuildContext context) { - final mutValue = useState(null); + // Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet + final chosen = useState<(T,)?>(null); return SheetLayout( title: Text(title), actions: [ @@ -20,7 +21,7 @@ final class const RadioSheet({ if (onChanged != null) TextButton( onPressed: () { - if (mutValue.value != null) onChanged!(mutValue.value as T); + if (chosen.value case (final picked,)) onChanged!(picked); Navigator.of(context).pop(); }, child: Text("OK"), @@ -28,8 +29,11 @@ final class const RadioSheet({ ], children: [ RadioGroup( - groupValue: mutValue.value ?? value, - onChanged: (value) => mutValue.value = value ?? mutValue.value, + groupValue: switch (chosen.value) { + (final picked,) => picked, + null => value, + }, + onChanged: (value) => chosen.value = (value as T,), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/widgets/settings/search_chooser.dart b/lib/widgets/settings/search_chooser.dart new file mode 100644 index 0000000..feadcdd --- /dev/null +++ b/lib/widgets/settings/search_chooser.dart @@ -0,0 +1,142 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; + +/// The [chosen] items, above a field searching [options] to choose more. +class const SearchChooser({ + required final TextEditingController controller, + required final ISet 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 new file mode 100644 index 0000000..7f6c527 --- /dev/null +++ b/lib/widgets/settings/settings_layout.dart @@ -0,0 +1,256 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:hooks_riverpod/misc.dart"; +import "package:m3e_card_list/m3e_card_list.dart"; +import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/settings_category.dart"; +import "package:nexus/widgets/pages/settings_category.dart"; +import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/highlight_wrapper.dart"; +import "package:super_sliver_list/super_sliver_list.dart"; + +/// A settings screen split into categories: a list of them that opens each +/// as its own page on narrow screens, or a rail beside the selected one. +class const SettingsLayout({ + required final String title, + required final ProviderListenable< + AsyncValue>> + > + 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 new file mode 100644 index 0000000..6f97eb0 --- /dev/null +++ b/lib/widgets/settings/text_setting_tile.dart @@ -0,0 +1,70 @@ +import "dart:async"; + +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +class const TextSettingTile({ + required final Icon icon, + required final String title, + required final String description, + required final String? value, + + /// Disables the tile if null, and while a returned future runs. + required final FutureOr 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 0ce98d1..9a15a1d 100644 --- a/lib/widgets/sheet_layout.dart +++ b/lib/widgets/sheet_layout.dart @@ -9,22 +9,34 @@ class const SheetLayout({ @override Widget build(BuildContext context) => Padding( padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom), - child: SingleChildScrollView( - padding: const .all(24), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .stretch, - spacing: 12, - children: [ - if (title case final title?) - DefaultTextStyle( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + if (title case final title?) + Padding( + padding: const .fromLTRB(24, 24, 24, 0), + child: DefaultTextStyle( style: Theme.of(context).textTheme.headlineSmall!, child: title, ), - ...children, - Row(mainAxisAlignment: .end, spacing: 8, children: actions), - ], - ), + ), + Flexible( + child: SingleChildScrollView( + padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: 12, + children: children, + ), + ), + ), + Padding( + padding: const .fromLTRB(24, 0, 24, 24), + child: Row(mainAxisAlignment: .end, spacing: 8, children: actions), + ), + ], ), ); } diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 85ae46c..33a891d 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -14,6 +14,7 @@ import "package:nexus/models/content/create.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), child: const Text("Leave"), ) + : room.summary.mustKnock(joinedIds) + ? TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => + KnockSheet(room.summary.roomId, name: name, via: child.via), + ), + child: const Text("Knock"), + ) : TextButton( onPressed: () => context.joinRoom( .new(roomIdOrAlias: room.summary.roomId, via: child.via), diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index b6da63d..bcab7fc 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,4 +1,5 @@ import "package:collection/collection.dart"; +import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -7,14 +8,18 @@ import "package:intl/intl.dart"; import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/mutual_rooms.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/mutual_rooms_sheet.dart"; import "package:nexus/models/membership_action.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; @@ -22,7 +27,6 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/start_dm_sheet.dart"; final class const UserBottomSheet( - final MembershipContent member, final String userId, { final String? roomId, super.key, @@ -35,6 +39,11 @@ final class const UserBottomSheet( color: theme.colorScheme.onSurfaceVariant, ); final client = ref.read(ClientController.provider.notifier); + // Green for accepting knocks, harmonized so it fits the current theme + final acceptScheme = ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), + brightness: theme.brightness, + ); void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( @@ -82,181 +91,248 @@ final class const UserBottomSheet( ), ); - return SingleChildScrollView( - child: Padding( - padding: .all(42), - child: Column( - spacing: 4, - mainAxisSize: .min, - crossAxisAlignment: .center, - children: [ - Row( - mainAxisAlignment: .end, - children: [ - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - SizedBox(height: 18), - - ExpandableImage( - member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), - downloadFilename: member.displayName ?? userId.localpart, - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - dimension: 200, - ), - ), - - SizedBox(height: 8), - - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineLarge, - textAlign: .center, - ), - SelectableText(userId, textAlign: .center, style: subtitleStyle), - - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: () => Text(""), - data: (profileResponse) => Column( - children: [ - if (profileResponse.profile.timezone == null && - profileResponse.profile.pronouns.isEmpty) - Text(""), - Wrap( - crossAxisAlignment: .center, - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - ...profileResponse.profile.pronouns - .where( - // TODO: Check system language (l10n) - (pronoun) => pronoun.language == "en", - ) - .mapIndexed( - (index, pronoun) => [ - if (index != 0) - Icon( - Icons.circle, - size: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - Text( - pronoun.summary, - textAlign: .center, - style: subtitleStyle, - ), - ], - ) - .flattened, - - if (profileResponse.profile.timezone != null) ...[ - if (profileResponse.profile.pronouns.isNotEmpty) - SizedBox( - height: 16, - child: VerticalDivider( - thickness: 1.5, - width: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - profileResponse.profile.timezone!, - textAlign: .center, - style: subtitleStyle, - ), - ], - ], - ), - ], - ), - ), - - SizedBox(height: 8), - if (userId != - ref.watch(ClientStateController.provider)?.userId) ...[ + return switch (ref.watch( + UserController.provider(.new(roomId: roomId, userId: userId)), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(value: final member) || + AsyncLoading(value: final member?) => SingleChildScrollView( + child: Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ Row( + mainAxisAlignment: .end, children: [ - Expanded( - child: M3EButton.icon( - onPressed: () async { - final name = member.displayName ?? userId.localpart; - final hasDm = ref - .read(RoomsController.provider) - .values - .any((room) => room.metadata?.dmUserId == userId); - - if (hasDm) { - context.startDirectMessage(userId, name: name); - } else if (await context.showAdaptiveSheet( - (_) => StartDmSheet(userId: userId, name: name), - ) != - true) { - return; - } - - if (context.mounted) Navigator.of(context).pop(); - }, - shape: .square, - style: .tonal, - icon: Icon(Icons.message), - label: Text("Message"), - ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), ), ], ), + SizedBox(height: 18), - if (roomId != null && - ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, + ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText(userId, textAlign: .center, style: subtitleStyle), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: () => Text(""), + data: (profileResponse) => Column( + children: [ + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profileResponse.profile.pronouns + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: + theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: subtitleStyle, + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != null) ...[ + if (profileResponse.profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profileResponse.profile.timezone!, + textAlign: .center, + style: subtitleStyle, + ), + ], + ], + ), + ], + ), + ), + + if (userId != ref.watch(ClientStateController.provider)?.userId) + switch (ref.watch(MutualRoomsController.provider(userId))) { + AsyncData(value: final roomIds) => switch (ref.watch( + RoomsController.provider.select( + (rooms) => roomIds.where(rooms.containsKey).length, + ), + )) { + 0 => SizedBox.shrink(), + final count => TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => MutualRoomsSheet(userId), + ), + child: Text( + "$count mutual ${Intl.plural(count, one: "room", other: "rooms")}", ), ), - )) - Padding( - padding: .only(top: 4), - child: Row( - spacing: 8, - children: [ - if (member.status == .join || member.status == .invite) - M3EButton.icon( - onPressed: () => showMembershipSheet(.kick), - shape: .square, - icon: Icon(Icons.sports_martial_arts), - label: Text("Kick"), - decoration: .styleFrom( - backgroundColor: theme.colorScheme.error, - foregroundColor: theme.colorScheme.onError, - ), - ), + }, + _ => SizedBox.shrink(), + }, - M3EButton.icon( - onPressed: () => showMembershipSheet(.ban), + SizedBox(height: 8), + if (userId != + ref.watch(ClientStateController.provider)?.userId) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: () async { + final name = member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any((room) => room.metadata?.dmUserId == userId); + + if (hasDm) { + context.startDirectMessage(userId, name: name); + } else if (await context.showAdaptiveSheet( + (_) => StartDmSheet(userId: userId, name: name), + ) != + true) { + return; + } + + if (context.mounted) Navigator.of(context).pop(); + }, shape: .square, - icon: Icon(Icons.gavel), - label: Text("Ban"), - decoration: .styleFrom( - backgroundColor: theme.colorScheme.errorContainer, - foregroundColor: theme.colorScheme.onErrorContainer, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + + if (roomId != null && + member.status == .knock && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .invite, + roomId: roomId!, + targetUser: userId, ), ), - ].map((e) => Expanded(child: e)).toList(), + )) + Padding( + padding: .only(top: 4), + child: Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: () => client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: .invite, + ), + ) + .onError(showError), + shape: .square, + icon: Icon(Icons.check), + label: Text("Accept knock"), + decoration: .styleFrom( + backgroundColor: acceptScheme.primary, + foregroundColor: acceptScheme.onPrimary, + ), + ), + ), + ], + ), ), - ), + + if (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + )) + Padding( + padding: .only(top: 4), + child: Row( + spacing: 8, + children: [ + if (member.status case .join || .invite || .knock) + M3EButton.icon( + onPressed: () => showMembershipSheet(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipSheet(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + ], ], - ], + ), ), ), - ); + AsyncLoading _ => Loading(), + }; } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b92986a..077376d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -12,9 +12,7 @@ #include #include #include -#include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) app_links_linux_registrar = @@ -35,13 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); - g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); - screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); - g_autoptr(FlPluginRegistrar) window_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); - window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 553a7ae..66d7f77 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -9,9 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux media_kit_video pasteboard - screen_retriever_linux url_launcher_linux - window_manager ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index 6b2b94d..a47f824 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -36,7 +36,13 @@ pkgs.mkShell { }) android.platform-tools ] - ++ lib.optional pkgs.stdenv.isLinux wayland; + ++ lib.optionals pkgs.stdenv.isLinux [ + wayland + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes + (callPackage ./pkg/hook_pkg_config.nix { }) + ]; env = rec { LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index 3e13fcb..bb6b2bf 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -3,6 +3,8 @@ callPackage, mpv-unwrapped, libass, + libxext, + libxfixes, libclang, flutter, stdenv, @@ -25,9 +27,14 @@ flutter.buildFlutterApplication { "--dart-define=GIT_COMMIT=${commit}" ]; + nativeBuildInputs = [ (callPackage ./hook_pkg_config.nix { }) ]; + buildInputs = [ mpv-unwrapped libass + # nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately + libxext + libxfixes ]; env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ]; @@ -42,9 +49,10 @@ flutter.buildFlutterApplication { m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY="; navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI="; - unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; - unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ="; + unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; + unifiedpush_platform_interface = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4="; webcrypto = "sha256-HW0NukbddwOYd9iVUFA3L8iy1qggnyOBgcbMj0MTYss="; + window_manager = "sha256-xUd5hG+/V6F+m0ZUL/3XGPACFG/zqI5JM6IaMVvLgx4="; xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc="; }; diff --git a/linux/nix/pkg/hook_pkg_config.nix b/linux/nix/pkg/hook_pkg_config.nix new file mode 100644 index 0000000..b5219e5 --- /dev/null +++ b/linux/nix/pkg/hook_pkg_config.nix @@ -0,0 +1,32 @@ +{ + stdenvNoCC, + pkg-config, + pkg-config-unwrapped, + makeWrapper, + gtk3, + libx11, + libxi, +}: + +# Build hooks run with a filtered environment that drops PKG_CONFIG_PATH, so +# nativeapi's hook can only find its libraries through a pkg-config on PATH. +stdenvNoCC.mkDerivation { + name = "hook-pkg-config"; + dontUnpack = true; + + nativeBuildInputs = [ + pkg-config + makeWrapper + ]; + + buildInputs = [ + gtk3 + libx11 + libxi + ]; + + installPhase = '' + makeWrapper ${pkg-config-unwrapped}/bin/pkg-config $out/bin/pkg-config \ + --prefix PKG_CONFIG_PATH : "$PKG_CONFIG_PATH" + ''; +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index bf6e5ef..f5795d0 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -155,11 +155,6 @@ static void my_application_activate( } #endif - gtk_widget_set_size_request( - GTK_WIDGET(window), - 250, - -1); - if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cc20897..f10315f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,11 +13,9 @@ import media_kit_libs_macos_video import media_kit_video import package_info_plus import pasteboard -import screen_retriever_macos import shared_preferences_foundation import url_launcher_macos import wakelock_plus -import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -28,9 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) - ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) - WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 05ea34c..d3101a9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,14 @@ 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: @@ -1041,6 +1049,22 @@ 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: @@ -1282,46 +1306,6 @@ 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: @@ -1539,8 +1523,8 @@ packages: dependency: "direct overridden" description: path: unifiedpush_linux - ref: "quad/feat/should-write-service-option" - resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" + ref: "quad/nexus" + resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" url: "https://codeberg.org/Henry-Hiles/flutter-connector" source: git version: "1.0.1" @@ -1548,8 +1532,8 @@ packages: dependency: "direct overridden" description: path: unifiedpush_platform_interface - ref: "quad/feat/should-write-service-option" - resolved-ref: "7589b07ef4280820e4adea165d3d86b219f6022c" + ref: "quad/nexus" + resolved-ref: "5b31fabcc260c2ff07c4499099ae20591e2c2cb9" url: "https://codeberg.org/Henry-Hiles/flutter-connector" source: git version: "4.0.0" @@ -1773,11 +1757,12 @@ packages: window_manager: dependency: "direct main" description: - name: window_manager - sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" - url: "https://pub.dev" - source: hosted - version: "0.5.2" + path: "." + ref: HEAD + resolved-ref: "80acd536a1ea1c121c36e32d30e638a295087ed1" + url: "https://github.com/leanflutter/window_manager" + source: git + version: "0.6.0" xdg_desktop_portal: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4eedbd9..47df5ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,17 +41,20 @@ dependency_overrides: unifiedpush_linux: git: url: https://codeberg.org/Henry-Hiles/flutter-connector - ref: quad/feat/should-write-service-option + ref: quad/nexus path: unifiedpush_linux unifiedpush_platform_interface: git: url: https://codeberg.org/Henry-Hiles/flutter-connector - ref: quad/feat/should-write-service-option + ref: quad/nexus 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 1d53353..7dc1c3c 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,9 +12,7 @@ #include #include #include -#include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -29,10 +27,6 @@ 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 05e3fad..68413e7 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,9 +9,7 @@ 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