feat(ui): gemeinsame Chatliste, Gruppenanrufe mit Beitreten-Karte, Bubble-Anrufansicht
- Chats: Direktnachrichten + Gruppen ohne Server zusammen, Filter-Chips (Alle/Ungelesen/Personen/Gruppen), Leerzustaende mit Handlung; virtuelles 'Raeume'-Symbol entfaellt, Server-Kanaele nur noch im Server - Gruppen: runder Gruppen-Avatar, Kopf mit Mitgliederzahl, Anruf-/Video-Knopf - Gruppenanruf: LiveKit + Anwesenheit, Start-Nachricht (Push), Karte 'Gruppenanruf laeuft · Beitreten', Hinweis-Karte im Verlauf - Anrufansicht: aneinandergedrueckte Bubbles (Federphysik, Sprecher waechst), Kacheln umschaltbar, Bildschirm automatisch gross, Gespraechsdauer, Matrix-Namen/-Avatare statt LiveKit-IDs - Tests: test/bubble_physics_test.dart Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
87b472a373
commit
de24053167
@@ -29,7 +29,7 @@ class _Header extends ConsumerWidget {
|
||||
|
||||
final name = isRealSpace
|
||||
? (activeSpace?.getLocalizedDisplayname() ?? 'Space')
|
||||
: (spaceId == 'dms' ? 'Chats' : 'Räume');
|
||||
: 'Chats';
|
||||
|
||||
void handleMenu(_SpaceMenuItem item) {
|
||||
switch (item) {
|
||||
@@ -101,7 +101,7 @@ class _Header extends ConsumerWidget {
|
||||
).then((confirmed) {
|
||||
if (confirmed == true) {
|
||||
space.leave().then((_) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -110,7 +110,7 @@ class _Header extends ConsumerWidget {
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<_SpaceMenuItem>> buildMenuItems() {
|
||||
if (spaceId == 'dms' || spaceId == null) {
|
||||
if (!isRealSpace) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.newDm,
|
||||
@@ -119,15 +119,10 @@ class _Header extends ConsumerWidget {
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createGroup,
|
||||
child: _MenuRow(Icons.group_add_rounded, 'Gruppe erstellen', pt)),
|
||||
];
|
||||
} else if (spaceId == 'rooms') {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.createRoom,
|
||||
child: _MenuRow(Icons.add_rounded, 'Raum erstellen', pt)),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.joinRoom,
|
||||
child: _MenuRow(Icons.login_rounded, 'Beitreten', pt)),
|
||||
child: _MenuRow(Icons.login_rounded, 'Raum beitreten', pt)),
|
||||
PopupMenuItem(
|
||||
value: _SpaceMenuItem.discoverRooms,
|
||||
child: _MenuRow(Icons.explore_rounded, 'Entdecken', pt)),
|
||||
|
||||
@@ -59,56 +59,66 @@ class _DraggableRoomItemState extends State<_DraggableRoomItem> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter der gemeinsamen Chatliste (Chips über der Liste).
|
||||
enum _ChatFilter { all, unread, people, groups }
|
||||
|
||||
final _chatFilterProvider =
|
||||
StateProvider<_ChatFilter>((ref) => _ChatFilter.all);
|
||||
|
||||
/// „Chats“: Direktnachrichten und Gruppen zusammen, nach letzter Aktivität
|
||||
/// (Bernd 2026-10-09). Kanäle eines Servers stehen nur in ihrem Server.
|
||||
class _RoomsList extends ConsumerWidget {
|
||||
final List<Room> rooms;
|
||||
final String filter;
|
||||
final String? spaceId;
|
||||
final Map<String, bool> collapsed;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<String> onToggleSection;
|
||||
|
||||
const _RoomsList({
|
||||
required this.rooms,
|
||||
required this.filter,
|
||||
required this.spaceId,
|
||||
required this.collapsed,
|
||||
required this.pt,
|
||||
required this.onToggleSection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeRoomId = ref.watch(activeRoomIdProvider);
|
||||
final isDms = spaceId == 'dms';
|
||||
final channels = ref.watch(spaceChannelIdsProvider);
|
||||
final chatFilter = ref.watch(_chatFilterProvider);
|
||||
final query = filter.toLowerCase();
|
||||
|
||||
final invites = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.invite &&
|
||||
isDms == r.isDirectChat)
|
||||
.toList();
|
||||
final invites =
|
||||
rooms.where((r) => r.membership == Membership.invite).toList();
|
||||
|
||||
final filtered = rooms.where((r) {
|
||||
if (r.membership == Membership.invite) return false;
|
||||
if (isDms != r.isDirectChat) return false;
|
||||
if (filter.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname()
|
||||
.toLowerCase()
|
||||
.contains(filter.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filtered.isEmpty && invites.isEmpty) {
|
||||
return Center(
|
||||
child:
|
||||
Text(isDms ? 'Noch keine Chats' : 'Keine Räume', style: TextStyle(color: pt.fgDim, fontSize: 13)));
|
||||
bool matches(Room r) {
|
||||
switch (chatFilter) {
|
||||
case _ChatFilter.all:
|
||||
break;
|
||||
case _ChatFilter.unread:
|
||||
if (!r.isUnread) return false;
|
||||
case _ChatFilter.people:
|
||||
if (!r.isDirectChat) return false;
|
||||
case _ChatFilter.groups:
|
||||
if (r.isDirectChat) return false;
|
||||
}
|
||||
if (query.isEmpty) return true;
|
||||
return r.getLocalizedDisplayname().toLowerCase().contains(query);
|
||||
}
|
||||
|
||||
final sectionTitle = isDms ? 'Direktnachrichten' : 'Räume';
|
||||
final sectionId = isDms ? 'dms' : 'rooms';
|
||||
final isCollapsed = collapsed[sectionId] ?? false;
|
||||
final chats = rooms
|
||||
.where((r) =>
|
||||
r.membership == Membership.join && isChatListRoom(r, channels))
|
||||
.toList();
|
||||
final filtered = chats.where(matches).toList();
|
||||
final unreadCount = chats.where((r) => r.isUnread).length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
children: [
|
||||
_ChatFilterChips(
|
||||
value: chatFilter,
|
||||
unreadCount: unreadCount,
|
||||
pt: pt,
|
||||
onChanged: (f) => ref.read(_chatFilterProvider.notifier).state = f,
|
||||
),
|
||||
if (invites.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 2),
|
||||
@@ -124,22 +134,18 @@ class _RoomsList extends ConsumerWidget {
|
||||
child: _InviteItem(room: r, pt: pt))),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
_SectionHeader(
|
||||
title: sectionTitle,
|
||||
count: filtered.length,
|
||||
isCollapsed: isCollapsed,
|
||||
pt: pt,
|
||||
onToggle: () => onToggleSection(sectionId),
|
||||
onAddTap: isDms
|
||||
? () => showDialog(
|
||||
context: context, builder: (_) => const NewDmDialog())
|
||||
: () => showDialog(
|
||||
context: context, builder: (_) => const CreateJoinDialog()),
|
||||
),
|
||||
if (!isCollapsed)
|
||||
if (filtered.isEmpty)
|
||||
_ChatListEmpty(
|
||||
filter: chatFilter,
|
||||
searching: query.isNotEmpty,
|
||||
hasInvites: invites.isNotEmpty,
|
||||
pt: pt,
|
||||
)
|
||||
else
|
||||
...filtered.map((room) {
|
||||
final isVoice = _isVoiceRoom(room);
|
||||
return _RoomItem(
|
||||
key: ValueKey('chat:${room.id}'),
|
||||
room: room,
|
||||
isActive: isVoice ? false : activeRoomId == room.id,
|
||||
isInVoice:
|
||||
@@ -163,6 +169,136 @@ class _RoomsList extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatFilterChips extends StatelessWidget {
|
||||
final _ChatFilter value;
|
||||
final int unreadCount;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<_ChatFilter> onChanged;
|
||||
|
||||
const _ChatFilterChips({
|
||||
required this.value,
|
||||
required this.unreadCount,
|
||||
required this.pt,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget chip(_ChatFilter f, String label) {
|
||||
final selected = value == f;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(selected ? _ChatFilter.all : f),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? pt.accentSoft : pt.bg2,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected ? pt.accent.withAlpha(120) : pt.border),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: selected ? pt.accent : pt.fgMuted,
|
||||
fontSize: 12.5,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 4, 6),
|
||||
children: [
|
||||
chip(_ChatFilter.all, 'Alle'),
|
||||
chip(_ChatFilter.unread,
|
||||
unreadCount > 0 ? 'Ungelesen · $unreadCount' : 'Ungelesen'),
|
||||
chip(_ChatFilter.people, 'Personen'),
|
||||
chip(_ChatFilter.groups, 'Gruppen'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leerzustand der Chatliste – mit Handlung statt nur „nichts da“.
|
||||
class _ChatListEmpty extends StatelessWidget {
|
||||
final _ChatFilter filter;
|
||||
final bool searching;
|
||||
final bool hasInvites;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _ChatListEmpty({
|
||||
required this.filter,
|
||||
required this.searching,
|
||||
required this.hasInvites,
|
||||
required this.pt,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (hasInvites && filter == _ChatFilter.all && !searching) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final (IconData icon, String text) = searching
|
||||
? (Icons.search_off_rounded, 'Nichts gefunden')
|
||||
: switch (filter) {
|
||||
_ChatFilter.unread => (Icons.done_all_rounded, 'Alles gelesen'),
|
||||
_ChatFilter.people => (
|
||||
Icons.person_outline_rounded,
|
||||
'Noch keine Unterhaltungen'
|
||||
),
|
||||
_ChatFilter.groups => (Icons.groups_outlined, 'Noch keine Gruppen'),
|
||||
_ChatFilter.all => (
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
'Noch keine Chats'
|
||||
),
|
||||
};
|
||||
final showAction = !searching && filter != _ChatFilter.unread;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 40, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 34, color: pt.fgDim),
|
||||
const SizedBox(height: 10),
|
||||
Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 14)),
|
||||
if (showAction) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: pt.accentSoft,
|
||||
foregroundColor: pt.accent,
|
||||
),
|
||||
icon: Icon(filter == _ChatFilter.groups
|
||||
? Icons.group_add_rounded
|
||||
: Icons.edit_rounded, size: 18),
|
||||
label: Text(filter == _ChatFilter.groups
|
||||
? 'Gruppe erstellen'
|
||||
: 'Neue Unterhaltung'),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => filter == _ChatFilter.groups
|
||||
? const CreateJoinDialog()
|
||||
: const NewDmDialog(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beitreten/Verlassen eines Voice-Channels aus der Raumliste – exakt das
|
||||
/// Verhalten von `joinVoiceChannel` in rooms_space_list.dart (Toggle beim
|
||||
/// aktiven Kanal, Wechsel legt vorher auf).
|
||||
@@ -187,84 +323,6 @@ Future<void> _toggleVoiceChannel(WidgetRef ref, Room room) async {
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatefulWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final bool isCollapsed;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback? onAddTap;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.isCollapsed,
|
||||
required this.pt,
|
||||
required this.onToggle,
|
||||
this.onAddTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SectionHeader> createState() => _SectionHeaderState();
|
||||
}
|
||||
|
||||
class _SectionHeaderState extends State<_SectionHeader> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onToggle,
|
||||
child: Row(children: [
|
||||
AnimatedRotation(
|
||||
turns: widget.isCollapsed ? -0.25 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Icon(Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: _hovered ? pt.fgMuted : pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11)),
|
||||
const Spacer(),
|
||||
Text('${widget.count}',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (_hovered && widget.onAddTap != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onAddTap,
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: Center(
|
||||
child: Icon(Icons.add, size: 12, color: pt.fgMuted)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 22),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomItem extends ConsumerStatefulWidget {
|
||||
final Room room;
|
||||
final bool isActive;
|
||||
@@ -574,6 +632,11 @@ class _RoomItemState extends ConsumerState<_RoomItem> {
|
||||
children: [
|
||||
if (isDm) ...[
|
||||
_DmAvatar(room: room, pt: pt),
|
||||
] else if (!isVoice &&
|
||||
!ref
|
||||
.watch(spaceChannelIdsProvider)
|
||||
.contains(room.id)) ...[
|
||||
_RoomTileAvatar(room: room, pt: pt, size: 22),
|
||||
] else ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
|
||||
@@ -119,34 +119,41 @@ class _MobileRoomTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar für Räume (kein DM): Raumbild oder abgerundetes Symbol.
|
||||
/// Avatar für Räume (kein DM): Raumbild oder Symbol. Gruppen (kein
|
||||
/// Server-Kanal) sind rund wie Personen, Server-Kanäle abgerundet mit „#“.
|
||||
class _RoomTileAvatar extends ConsumerWidget {
|
||||
final Room room;
|
||||
final PyramidTheme pt;
|
||||
const _RoomTileAvatar({required this.room, required this.pt});
|
||||
final double size;
|
||||
const _RoomTileAvatar(
|
||||
{required this.room, required this.pt, this.size = _kTileAvatar});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
final isChannel = ref.watch(spaceChannelIdsProvider).contains(room.id);
|
||||
final radius = BorderRadius.circular(isChannel ? size * 0.3 : size / 2);
|
||||
final fallback = Container(
|
||||
width: _kTileAvatar,
|
||||
height: _kTileAvatar,
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: isChannel ? pt.bg3 : pt.accentSoft,
|
||||
borderRadius: radius,
|
||||
),
|
||||
child: Icon(
|
||||
room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
|
||||
size: 22,
|
||||
color: pt.fgMuted,
|
||||
isChannel
|
||||
? (room.encrypted ? Icons.lock_outline_rounded : Icons.tag_rounded)
|
||||
: Icons.groups_rounded,
|
||||
size: size * 0.5,
|
||||
color: isChannel ? pt.fgMuted : pt.accent,
|
||||
),
|
||||
);
|
||||
if (client == null || room.avatar == null) return fallback;
|
||||
return MxcAvatar(
|
||||
mxcUri: room.avatar,
|
||||
client: client,
|
||||
size: _kTileAvatar,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
size: size,
|
||||
borderRadius: radius,
|
||||
placeholder: (_) => fallback,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,12 +118,7 @@ class _RoomsPanelState extends ConsumerState<RoomsPanel> {
|
||||
data: (rooms) => _RoomsList(
|
||||
rooms: rooms,
|
||||
filter: _filter,
|
||||
spaceId: activeSpaceId,
|
||||
collapsed: _collapsed,
|
||||
pt: pt,
|
||||
onToggleSection: (id) => setState(() {
|
||||
_collapsed[id] = !(_collapsed[id] ?? false);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -40,7 +40,7 @@ class _SpaceInviteViewState extends ConsumerState<_SpaceInviteView> {
|
||||
try {
|
||||
await widget.space.leave();
|
||||
if (mounted) {
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'rooms';
|
||||
ref.read(activeSpaceIdProvider.notifier).state = 'dms';
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _declining = false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/notification_content.dart' show notificationBody;
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
import 'package:pyramid/core/theme.dart';
|
||||
import 'package:pyramid/features/rooms/rooms_provider.dart';
|
||||
import 'package:pyramid/features/chat/chat_provider.dart' show acceptInvite;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pyramid/core/matrix_client.dart';
|
||||
import 'package:pyramid/core/room_kinds.dart';
|
||||
|
||||
final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
@@ -12,19 +13,33 @@ final roomListProvider = StreamProvider<List<Room>>((ref) async* {
|
||||
}
|
||||
});
|
||||
|
||||
/// IDs aller Server-Kanäle (siehe [spaceChannelIds]); wird mit jeder
|
||||
/// Synchronisation neu berechnet.
|
||||
final spaceChannelIdsProvider = Provider<Set<String>>((ref) {
|
||||
ref.watch(roomListProvider);
|
||||
final client = ref.watch(matrixClientProvider).valueOrNull;
|
||||
return client == null ? const {} : spaceChannelIds(client);
|
||||
});
|
||||
|
||||
/// Ungelesene Unterhaltungen in „Chats“ (Direktnachrichten + Gruppen ohne
|
||||
/// Server) – für das Abzeichen am Chats-Symbol.
|
||||
final dmUnreadCountProvider = StreamProvider<int>((ref) async* {
|
||||
final client = await ref.watch(matrixClientProvider.future);
|
||||
|
||||
yield _countDmUnread(client);
|
||||
yield _countChatUnread(client);
|
||||
|
||||
await for (final _ in client.onSync.stream) {
|
||||
yield _countDmUnread(client);
|
||||
yield _countChatUnread(client);
|
||||
}
|
||||
});
|
||||
|
||||
int _countDmUnread(Client client) {
|
||||
int _countChatUnread(Client client) {
|
||||
final channels = spaceChannelIds(client);
|
||||
return client.rooms
|
||||
.where((r) => !r.isSpace && r.isDirectChat && r.notificationCount > 0)
|
||||
.where((r) =>
|
||||
r.membership == Membership.join &&
|
||||
isChatListRoom(r, channels) &&
|
||||
r.notificationCount > 0)
|
||||
.length;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user