part of '../rooms_panel.dart'; class _SpaceRoomsList extends ConsumerStatefulWidget { final String spaceId; final String filter; final Map collapsed; final PyramidTheme pt; final ValueChanged onToggleSection; const _SpaceRoomsList({ required this.spaceId, required this.filter, required this.collapsed, required this.pt, required this.onToggleSection, }); @override ConsumerState<_SpaceRoomsList> createState() => _SpaceRoomsListState(); } class _SpaceRoomsListState extends ConsumerState<_SpaceRoomsList> { // Local order override after drag-and-drop (room IDs in order per category key) final Map> _localOrder = {}; // Joinable channels discovered via the space hierarchy (not yet a member). List<_JoinableRoom> _joinable = []; final Set _joining = {}; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final client = ref.read(matrixClientProvider).valueOrNull; if (client != null) _loadHierarchy(client); }); } @override void didUpdateWidget(covariant _SpaceRoomsList old) { super.didUpdateWidget(old); if (old.spaceId != widget.spaceId) { setState(() => _joinable = []); final client = ref.read(matrixClientProvider).valueOrNull; if (client != null) _loadHierarchy(client); } } /// Queries the server-side space hierarchy and keeps only rooms the user is /// not yet a member of — these are the channels they can still join. Future _loadHierarchy(Client client) async { try { final resp = await client.getSpaceHierarchy( widget.spaceId, maxDepth: 3, suggestedOnly: false, ); final joinable = <_JoinableRoom>[]; for (final chunk in resp.rooms) { if (chunk.roomId == widget.spaceId) continue; if (chunk.roomType == 'm.space') continue; // skip sub-spaces // Keep every channel the hierarchy reports. The build method filters out // the ones already rendered in the joined sections — relying on local // membership here is unreliable because Continuwuity's sync lags behind. joinable.add(_JoinableRoom( roomId: chunk.roomId, name: chunk.name ?? chunk.canonicalAlias ?? chunk.roomId, isVoice: chunk.roomType == _kVoiceRoomType, memberCount: chunk.numJoinedMembers, )); } if (mounted) setState(() => _joinable = joinable); } catch (_) { // Hierarchy endpoint unavailable / no permission — silently ignore. } } Future _joinAndEnter(Client client, _JoinableRoom jr) async { setState(() => _joining.add(jr.roomId)); try { await client.joinRoomById(jr.roomId).timeout(const Duration(seconds: 20)); // Give the sync a brief chance to deliver the room object, but don't block // on it — the local membership may lag behind the server. Room? room = client.getRoomById(jr.roomId); if (room == null) { try { await client.onSync.stream .firstWhere((_) => client.getRoomById(jr.roomId) != null) .timeout(const Duration(seconds: 4)); } catch (_) {} room = client.getRoomById(jr.roomId); } if (!mounted) return; setState(() => _joining.remove(jr.roomId)); if (jr.isVoice) { // LiveKit only needs the room id + a display name, so connect even if the // matrix room object hasn't synced locally yet. final call = ref.read(voiceChannelProvider); if (call.isActive) await call.hangUp(); ref.read(activeVoiceRoomIdProvider.notifier).state = jr.roomId; await call.startCall( roomName: jr.roomId, roomDisplayName: room?.getLocalizedDisplayname() ?? jr.name, identity: client.userID ?? '', audioOnly: true, voiceChannel: true, matrixClient: client, matrixRoomId: jr.roomId, ); } else if (room != null) { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } } catch (e) { if (mounted) { setState(() => _joining.remove(jr.roomId)); ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Beitritt fehlgeschlagen: ${e.toString().split('\n').first}'), backgroundColor: widget.pt.danger, )); } } } Future _applyReorder( Room space, String categoryKey, List newOrder) async { try { for (var i = 0; i < newOrder.length; i++) { final child = newOrder[i]; if (child.roomId == null) continue; final newOrd = (i + 1).toString().padLeft(4, '0'); if (child.order != newOrd) { await space.setSpaceChild(child.roomId!, via: child.via.isEmpty ? null : child.via, order: newOrd); } } } catch (_) { } } @override Widget build(BuildContext context) { // Rebuild when rooms list changes (sync) ref.watch(roomListProvider); final client = ref.watch(matrixClientProvider).valueOrNull; final activeRoomId = ref.watch(activeRoomIdProvider); if (client == null) { return Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: widget.pt.accent))); } final space = client.getRoomById(widget.spaceId); if (space == null) { return Center( child: Text('Space nicht gefunden', style: TextStyle(color: widget.pt.fgDim, fontSize: 12))); } final isAdmin = space.ownPowerLevel >= 50; final allChildren = space.spaceChildren; // Separate sub-spaces (categories) from direct rooms final subSpaceEntries = []; final directEntries = []; for (final c in allChildren) { if (c.roomId == null) continue; final room = client.getRoomById(c.roomId!); if (room == null || room.membership == Membership.leave) continue; if (room.isSpace) { subSpaceEntries.add(c); } else { directEntries.add(c); } } subSpaceEntries.sort(_compareSpaceChildren); directEntries.sort(_compareSpaceChildren); // Collect invited rooms final invites = allChildren .where((c) => c.roomId != null) .map((c) => client.getRoomById(c.roomId!)) .whereType() .where((r) => r.membership == Membership.invite) .toList(); // IDs already rendered in the joined/invited sections — used to avoid showing // them again under "available channels" from the hierarchy. final shownIds = {...invites.map((r) => r.id)}; void selectRoom(Room room) { ref.read(activeRoomIdProvider.notifier).state = room.id; ref.read(viewModeProvider.notifier).state = ViewMode.chat; } void joinVoiceChannel(Room room) async { final call = ref.read(voiceChannelProvider); final activeVoice = ref.read(activeVoiceRoomIdProvider); // Toggle: leave if already in this channel if (call.isVoiceChannel && activeVoice == room.id) { ref.read(activeVoiceRoomIdProvider.notifier).state = null; await call.hangUp(); return; } if (call.isActive) await call.hangUp(); ref.read(activeVoiceRoomIdProvider.notifier).state = room.id; final userId = client.userID ?? ''; await call.startCall( roomName: room.id, roomDisplayName: room.getLocalizedDisplayname(), identity: userId, audioOnly: true, voiceChannel: true, matrixClient: client, matrixRoomId: room.id, ); } // Build category sections for sub-spaces List categoryWidgets = []; for (final catEntry in subSpaceEntries) { final subSpace = client.getRoomById(catEntry.roomId!)!; final catId = subSpace.id; final catName = subSpace.getLocalizedDisplayname(); final isCollapsed = widget.collapsed[catId] ?? false; final catChildren = subSpace.spaceChildren .where((c) => c.roomId != null) .map((c) => client.getRoomById(c.roomId!)) .whereType() .where((r) => r.membership != Membership.leave && !r.isSpace) .toList(); shownIds.addAll(catChildren.map((r) => r.id)); final filtered = catChildren .where((r) => widget.filter.isEmpty || r.getLocalizedDisplayname() .toLowerCase() .contains(widget.filter.toLowerCase())) .toList(); categoryWidgets.add(_CategoryHeader( name: catName.toUpperCase(), isCollapsed: isCollapsed, pt: widget.pt, onTap: () => widget.onToggleSection(catId), )); if (!isCollapsed) { for (final room in filtered) { final isVoice = _isVoiceRoom(room); categoryWidgets.add(_RoomItem( key: ValueKey('cat_${room.id}'), room: room, isActive: isVoice ? false : activeRoomId == room.id, isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == room.id, pt: widget.pt, onTap: () => isVoice ? joinVoiceChannel(room) : selectRoom(room), )); } } } // Build direct rooms section (reorderable for admin) final directRooms = directEntries .map((c) => client.getRoomById(c.roomId!)) .whereType() .toList(); shownIds.addAll(directRooms.map((r) => r.id)); // Apply local order override if present final localOrd = _localOrder['__direct__']; if (localOrd != null) { directRooms.sort((a, b) { final ai = localOrd.indexOf(a.id); final bi = localOrd.indexOf(b.id); if (ai == -1 && bi == -1) return 0; if (ai == -1) return 1; if (bi == -1) return -1; return ai.compareTo(bi); }); } final filteredDirect = directRooms .where((r) => widget.filter.isEmpty || r.getLocalizedDisplayname() .toLowerCase() .contains(widget.filter.toLowerCase())) .toList(); // Joinable channels from the hierarchy that aren't already listed above. final visibleJoinable = _joinable .where((jr) => !shownIds.contains(jr.roomId) && (widget.filter.isEmpty || jr.name.toLowerCase().contains(widget.filter.toLowerCase()))) .toList(); // Build the complete list return ListView( padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), children: [ // Invite cards if (invites.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 6, 8, 2), child: Text('EINLADUNGEN', style: TextStyle( color: widget.pt.accent, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.6)), ), ...invites.map((r) => Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: _InviteItem(room: r, pt: widget.pt))), const SizedBox(height: 4), ], // Sub-space categories ...categoryWidgets, // Direct rooms if (filteredDirect.isNotEmpty) ...[ if (subSpaceEntries.isNotEmpty) _CategoryHeader( name: 'KANÄLE', isCollapsed: widget.collapsed['__direct__'] ?? false, pt: widget.pt, onTap: () => widget.onToggleSection('__direct__'), ), if (!(widget.collapsed['__direct__'] ?? false)) if (isAdmin) // Reorderable list for admins ReorderableListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, onReorderItem: (oldIdx, newIdx) { final ids = filteredDirect.map((r) => r.id).toList(); final moved = ids.removeAt(oldIdx); ids.insert(newIdx, moved); setState(() => _localOrder['__direct__'] = ids); // Apply to Matrix final newOrder = ids .map((id) => directEntries.firstWhere((c) => c.roomId == id, orElse: () => directEntries.first)) .toList(); _applyReorder(space, '__direct__', newOrder); }, children: [ for (var i = 0; i < filteredDirect.length; i++) _DraggableRoomItem( key: ValueKey(filteredDirect[i].id), room: filteredDirect[i], index: i, isActive: _isVoiceRoom(filteredDirect[i]) ? false : activeRoomId == filteredDirect[i].id, isInVoice: _isVoiceRoom(filteredDirect[i]) && ref.read(activeVoiceRoomIdProvider) == filteredDirect[i].id, pt: widget.pt, onTap: () => _isVoiceRoom(filteredDirect[i]) ? joinVoiceChannel(filteredDirect[i]) : selectRoom(filteredDirect[i]), ), ], ) else ...filteredDirect.map((r) { final isVoice = _isVoiceRoom(r); return _RoomItem( key: ValueKey(r.id), room: r, isActive: isVoice ? false : activeRoomId == r.id, isInVoice: isVoice && ref.read(activeVoiceRoomIdProvider) == r.id, pt: widget.pt, onTap: () => isVoice ? joinVoiceChannel(r) : selectRoom(r), ); }), ], // Joinable channels from the space hierarchy (not yet a member) if (visibleJoinable.isNotEmpty) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 2), child: Text('VERFÜGBARE KANÄLE', style: TextStyle( color: widget.pt.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.6)), ), ...visibleJoinable.map((jr) => _JoinableRoomItem( jr: jr, joining: _joining.contains(jr.roomId), pt: widget.pt, onJoin: () => _joinAndEnter(client, jr), )), ], if (filteredDirect.isEmpty && categoryWidgets.isEmpty && invites.isEmpty && visibleJoinable.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Center( child: Text('Keine Kanäle', style: TextStyle(color: widget.pt.fgDim, fontSize: 13))), ), ], ); } } class _JoinableRoom { final String roomId; final String name; final bool isVoice; final int memberCount; const _JoinableRoom({ required this.roomId, required this.name, required this.isVoice, required this.memberCount, }); } class _JoinableRoomItem extends StatefulWidget { final _JoinableRoom jr; final bool joining; final PyramidTheme pt; final VoidCallback onJoin; const _JoinableRoomItem({ required this.jr, required this.joining, required this.pt, required this.onJoin, }); @override State<_JoinableRoomItem> createState() => _JoinableRoomItemState(); } class _JoinableRoomItemState extends State<_JoinableRoomItem> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; final jr = widget.jr; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.joining ? null : widget.onJoin, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric(vertical: 1), padding: EdgeInsets.symmetric( horizontal: pt.rowPadX, vertical: pt.rowPadY), decoration: BoxDecoration( color: _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( children: [ SizedBox( width: 16, height: 16, child: Icon( jr.isVoice ? Icons.mic_rounded : Icons.tag_rounded, size: 14, color: pt.fgDim, ), ), const SizedBox(width: 8), Expanded( child: Text( jr.name, style: TextStyle( color: pt.fgMuted, fontSize: 14, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis, ), ), if (widget.joining) const SizedBox( width: 14, height: 14, child: CircularProgressIndicator.adaptive(strokeWidth: 2), ) else Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: _hovered ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(6), ), child: Text( 'Beitreten', style: TextStyle( color: _hovered ? pt.accentFg : pt.fgMuted, fontSize: 11, fontWeight: FontWeight.w600), ), ), ], ), ), ), ); } } class _CategoryHeader extends StatefulWidget { final String name; final bool isCollapsed; final PyramidTheme pt; final VoidCallback onTap; const _CategoryHeader( {required this.name, required this.isCollapsed, required this.pt, required this.onTap}); @override State<_CategoryHeader> createState() => _CategoryHeaderState(); } class _CategoryHeaderState extends State<_CategoryHeader> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Padding( padding: const EdgeInsets.fromLTRB(8, 10, 8, 2), child: Row(children: [ AnimatedRotation( turns: widget.isCollapsed ? -0.25 : 0, duration: const Duration(milliseconds: 180), child: Icon(Icons.keyboard_arrow_down_rounded, size: 12, color: _hovered ? pt.fgMuted : pt.fgDim), ), const SizedBox(width: 4), Text( widget.name, style: TextStyle( color: _hovered ? pt.fgMuted : pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.5, ), ), ]), ), ), ); } }