import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat_list/unread_bubble.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/matrix.dart'; enum AddRoomType { chat, subspace } enum SpaceChildAction { edit, moveToSpace, removeFromSpace } enum SpaceActions { settings, invite, members, leave, } class SpaceView extends StatefulWidget { final String spaceId; final void Function() onBack; final void Function(Room room) onChatTab; final String? activeChat; const SpaceView({ required this.spaceId, required this.onBack, required this.onChatTab, required this.activeChat, super.key, }); @override State createState() => _SpaceViewState(); } class _SpaceViewState extends State { final List _discoveredChildren = []; final TextEditingController _filterController = TextEditingController(); String? _nextBatch; bool _noMoreRooms = false; bool _isLoading = false; @override void initState() { _loadHierarchy(); super.initState(); } void _loadHierarchy() async { final matrix = Matrix.of(context); final room = matrix.client.getRoomById(widget.spaceId); if (room == null) return; final cacheKey = 'spaces_history_cache${room.id}'; if (_discoveredChildren.isEmpty) { final cachedChildren = matrix.store.getStringList(cacheKey); if (cachedChildren != null) { try { _discoveredChildren.addAll( cachedChildren.map( (jsonString) => SpaceRoomsChunk$2.fromJson(jsonDecode(jsonString)), ), ); } catch (e, s) { Logs().e('Unable to json decode spaces hierarchy cache!', e, s); matrix.store.remove(cacheKey); } } } setState(() { _isLoading = true; }); try { final hierarchy = await room.client.getSpaceHierarchy( widget.spaceId, suggestedOnly: false, maxDepth: 2, from: _nextBatch, ); if (!mounted) return; setState(() { if (_nextBatch == null) _discoveredChildren.clear(); _nextBatch = hierarchy.nextBatch; if (hierarchy.nextBatch == null) { _noMoreRooms = true; } _discoveredChildren.addAll( hierarchy.rooms.where((room) => room.roomId != widget.spaceId), ); _isLoading = false; }); if (_nextBatch == null) { matrix.store.setStringList( cacheKey, _discoveredChildren .map((child) => jsonEncode(child.toJson())) .toList(), ); } } catch (e, s) { Logs().w('Unable to load hierarchy', e, s); if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toLocalizedString(context)))); setState(() { _isLoading = false; }); } } void _joinChildRoom(SpaceRoomsChunk$2 item) async { final client = Matrix.of(context).client; final space = client.getRoomById(widget.spaceId); final joined = await showAdaptiveDialog( context: context, builder: (_) => PublicRoomDialog( chunk: item, via: space?.spaceChildren .firstWhereOrNull( (child) => child.roomId == item.roomId, ) ?.via, ), ); if (mounted && joined == true) { setState(() {}); } } void _onSpaceAction(SpaceActions action) async { final space = Matrix.of(context).client.getRoomById(widget.spaceId); switch (action) { case SpaceActions.settings: await space?.postLoad(); context.push('/rooms/${widget.spaceId}/details'); break; case SpaceActions.invite: await space?.postLoad(); context.push('/rooms/${widget.spaceId}/invite'); break; case SpaceActions.members: await space?.postLoad(); context.push('/rooms/${widget.spaceId}/details/members'); break; case SpaceActions.leave: final confirmed = await showOkCancelAlertDialog( context: context, title: L10n.of(context).areYouSure, message: L10n.of(context).archiveRoomDescription, okLabel: L10n.of(context).leave, cancelLabel: L10n.of(context).cancel, isDestructive: true, ); if (!mounted) return; if (confirmed != OkCancelResult.ok) return; final success = await showFutureLoadingDialog( context: context, future: () async => await space?.leave(), ); if (!mounted) return; if (success.error != null) return; widget.onBack(); } } void _addChatOrSubspace(AddRoomType roomType) async { final names = await showTextInputDialog( context: context, title: roomType == AddRoomType.subspace ? L10n.of(context).newSubSpace : L10n.of(context).createGroup, hintText: roomType == AddRoomType.subspace ? L10n.of(context).spaceName : L10n.of(context).groupName, minLines: 1, maxLines: 1, maxLength: 64, validator: (text) { if (text.isEmpty) { return L10n.of(context).pleaseChoose; } return null; }, okLabel: L10n.of(context).create, cancelLabel: L10n.of(context).cancel, ); if (names == null) return; final client = Matrix.of(context).client; final result = await showFutureLoadingDialog( context: context, future: () async { late final String roomId; final activeSpace = client.getRoomById(widget.spaceId)!; await activeSpace.postLoad(); final isPublicSpace = activeSpace.joinRules == JoinRules.public; if (roomType == AddRoomType.subspace) { roomId = await client.createSpace( name: names, visibility: isPublicSpace ? sdk.Visibility.public : sdk.Visibility.private, ); } else { roomId = await client.createGroupChat( enableEncryption: !isPublicSpace, groupName: names, preset: isPublicSpace ? CreateRoomPreset.publicChat : CreateRoomPreset.privateChat, visibility: isPublicSpace ? sdk.Visibility.public : sdk.Visibility.private, initialState: isPublicSpace ? null : [ StateEvent( content: { 'join_rule': 'restricted', 'allow': [ { 'room_id': widget.spaceId, 'type': 'm.room_membership', }, ], }, type: EventTypes.RoomJoinRules, ), ], ); } await activeSpace.setSpaceChild(roomId); }, ); if (result.error != null) return; setState(() { _nextBatch = null; _discoveredChildren.clear(); }); _loadHierarchy(); } void _showSpaceChildEditMenu(BuildContext posContext, String roomId) async { final overlay = Overlay.of(posContext).context.findRenderObject() as RenderBox; final button = posContext.findRenderObject() as RenderBox; final position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(const Offset(0, -65), ancestor: overlay), button.localToGlobal( button.size.bottomRight(Offset.zero) + const Offset(-50, 0), ancestor: overlay, ), ), Offset.zero & overlay.size, ); final action = await showMenu( context: posContext, position: position, items: [ PopupMenuItem( value: SpaceChildAction.moveToSpace, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.move_down_outlined), const SizedBox(width: 12), Text(L10n.of(context).moveToDifferentSpace), ], ), ), PopupMenuItem( value: SpaceChildAction.edit, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.edit_outlined), const SizedBox(width: 12), Text(L10n.of(context).edit), ], ), ), PopupMenuItem( value: SpaceChildAction.removeFromSpace, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.group_remove_outlined), const SizedBox(width: 12), Text(L10n.of(context).removeFromSpace), ], ), ), ], ); if (action == null) return; if (!mounted) return; final space = Matrix.of(context).client.getRoomById(widget.spaceId); if (space == null) return; switch (action) { case SpaceChildAction.edit: context.push('/rooms/${widget.spaceId}/details'); case SpaceChildAction.moveToSpace: final spacesWithPowerLevels = space.client.rooms .where( (room) => room.isSpace && room.canChangeStateEvent(EventTypes.SpaceChild) && room.id != widget.spaceId, ) .toList(); final newSpace = await showModalActionPopup( context: context, title: L10n.of(context).space, actions: spacesWithPowerLevels .map( (space) => AdaptiveModalAction( value: space, label: space .getLocalizedDisplayname(MatrixLocals(L10n.of(context))), ), ) .toList(), ); if (newSpace == null) return; final result = await showFutureLoadingDialog( context: context, future: () async { await newSpace.setSpaceChild(newSpace.id); await space.removeSpaceChild(roomId); }, ); if (result.isError) return; if (!mounted) return; _nextBatch = null; _loadHierarchy(); return; case SpaceChildAction.removeFromSpace: final consent = await showOkCancelAlertDialog( context: context, title: L10n.of(context).removeFromSpace, message: L10n.of(context).removeFromSpaceDescription, ); if (consent != OkCancelResult.ok) return; if (!mounted) return; final result = await showFutureLoadingDialog( context: context, future: () => space.removeSpaceChild(roomId), ); if (result.isError) return; if (!mounted) return; _nextBatch = null; _loadHierarchy(); return; } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final room = Matrix.of(context).client.getRoomById(widget.spaceId); final displayname = room?.getLocalizedDisplayname() ?? L10n.of(context).nothingFound; const avatarSize = Avatar.defaultSize / 1.5; final isAdmin = room?.canChangeStateEvent( EventTypes.SpaceChild, ) == true; return Scaffold( appBar: AppBar( leading: FluffyThemes.isColumnMode(context) ? null : Center( child: CloseButton( onPressed: widget.onBack, ), ), automaticallyImplyLeading: false, titleSpacing: FluffyThemes.isColumnMode(context) ? null : 0, title: ListTile( contentPadding: EdgeInsets.zero, leading: Avatar( size: avatarSize, mxContent: room?.avatar, name: displayname, border: BorderSide(width: 1, color: theme.dividerColor), borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), ), title: Text( displayname, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), actions: [ if (isAdmin) PopupMenuButton( icon: const Icon(Icons.add_outlined), onSelected: _addChatOrSubspace, tooltip: L10n.of(context).addChatOrSubSpace, itemBuilder: (context) => [ PopupMenuItem( value: AddRoomType.chat, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.group_add_outlined), const SizedBox(width: 12), Text(L10n.of(context).newGroup), ], ), ), PopupMenuItem( value: AddRoomType.subspace, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.workspaces_outlined), const SizedBox(width: 12), Text(L10n.of(context).newSubSpace), ], ), ), ], ), PopupMenuButton( useRootNavigator: true, onSelected: _onSpaceAction, itemBuilder: (context) => [ PopupMenuItem( value: SpaceActions.settings, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.settings_outlined), const SizedBox(width: 12), Text(L10n.of(context).settings), ], ), ), PopupMenuItem( value: SpaceActions.invite, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.person_add_outlined), const SizedBox(width: 12), Text(L10n.of(context).invite), ], ), ), PopupMenuItem( value: SpaceActions.members, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.group_outlined), const SizedBox(width: 12), Text( L10n.of(context).countParticipants( room?.summary.mJoinedMemberCount ?? 1, ), ), ], ), ), PopupMenuItem( value: SpaceActions.leave, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.delete_outlined), const SizedBox(width: 12), Text(L10n.of(context).leave), ], ), ), ], ), ], ), body: room == null ? const Center( child: Icon( Icons.search_outlined, size: 80, ), ) : StreamBuilder( stream: room.client.onSync.stream .where((s) => s.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)), builder: (context, snapshot) { final filter = _filterController.text.trim().toLowerCase(); return CustomScrollView( slivers: [ SliverAppBar( floating: true, scrolledUnderElevation: 0, backgroundColor: Colors.transparent, automaticallyImplyLeading: false, title: TextField( controller: _filterController, onChanged: (_) => setState(() {}), textInputAction: TextInputAction.search, decoration: InputDecoration( filled: true, contentPadding: EdgeInsets.zero, hintText: L10n.of(context).search, hintStyle: TextStyle( color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.normal, ), floatingLabelBehavior: FloatingLabelBehavior.never, prefixIcon: IconButton( onPressed: () {}, icon: Icon( Icons.search_outlined, color: theme.colorScheme.onPrimaryContainer, ), ), ), ), ), SliverList.builder( itemCount: _discoveredChildren.length + 1, itemBuilder: (context, i) { if (i == _discoveredChildren.length) { if (_noMoreRooms) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0, vertical: 2.0, ), child: TextButton( onPressed: _isLoading ? null : _loadHierarchy, child: _isLoading ? const CircularProgressIndicator.adaptive() : Text(L10n.of(context).loadMore), ), ); } final item = _discoveredChildren[i]; final displayname = item.name ?? item.canonicalAlias ?? L10n.of(context).emptyChat; if (!displayname.toLowerCase().contains(filter)) { return const SizedBox.shrink(); } var joinedRoom = room.client.getRoomById(item.roomId); if (joinedRoom?.membership == Membership.leave) { joinedRoom = null; } return Padding( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 1, ), child: Material( borderRadius: BorderRadius.circular(AppConfig.borderRadius), clipBehavior: Clip.hardEdge, color: joinedRoom != null && widget.activeChat == joinedRoom.id ? theme.colorScheme.secondaryContainer : Colors.transparent, child: HoverBuilder( builder: (context, hovered) => ListTile( visualDensity: const VisualDensity(vertical: -0.5), contentPadding: const EdgeInsets.symmetric(horizontal: 8), onTap: joinedRoom != null ? () => widget.onChatTab(joinedRoom!) : () => _joinChildRoom(item), onLongPress: isAdmin ? () => _showSpaceChildEditMenu( context, item.roomId, ) : null, leading: hovered && isAdmin ? SizedBox.square( dimension: avatarSize, child: IconButton( splashRadius: avatarSize, iconSize: 14, style: IconButton.styleFrom( foregroundColor: theme.colorScheme .onTertiaryContainer, backgroundColor: theme .colorScheme.tertiaryContainer, ), onPressed: () => _showSpaceChildEditMenu( context, item.roomId, ), icon: const Icon(Icons.edit_outlined), ), ) : Avatar( size: avatarSize, mxContent: item.avatarUrl, name: '#', backgroundColor: theme.colorScheme.surfaceContainer, textColor: item.name?.darkColor ?? theme.colorScheme.onSurface, border: item.roomType == 'm.space' ? BorderSide( color: theme.colorScheme .surfaceContainerHighest, ) : null, borderRadius: item.roomType == 'm.space' ? BorderRadius.circular( AppConfig.borderRadius / 4, ) : null, ), title: Row( children: [ Expanded( child: Opacity( opacity: joinedRoom == null ? 0.5 : 1, child: Text( displayname, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), if (joinedRoom != null) UnreadBubble(room: joinedRoom) else const Icon(Icons.chevron_right_outlined), ], ), ), ), ), ); }, ), const SliverPadding(padding: EdgeInsets.only(top: 32)), ], ); }, ), ); } }