import 'dart:async' show unawaited; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import '../services/channel_spacer.dart'; import '../services/link_trust_service.dart'; import '../services/snapshot_state_mapper.dart'; import '../services/ts3_server_link.dart'; import '../src/rust/api.dart' as rust; import 'bbcode_text.dart'; /// One chat/activity message shown in the chat hub. class ChatEntry { /// Construct a chat entry. const ChatEntry({ required this.senderId, required this.senderName, required this.message, required this.target, this.isSelf = false, this.timestamp, }); /// Sender client id. final BigInt senderId; /// Sender display name. final String senderName; /// Message body. final String message; /// Chat target. final rust.BridgeMessageTarget target; /// True when the local client sent this message. final bool isSelf; /// Local time when this message was received or sent. final DateTime? timestamp; /// True for direct-message targets. bool get isPrivate => target is rust.BridgeMessageTarget_Client; /// True for poke targets. bool get isPoke => target is rust.BridgeMessageTarget_Poke; } String chatTimeLabel(DateTime? timestamp) { if (timestamp == null) return '--:--:--'; String two(int value) => value.toString().padLeft(2, '0'); return '${two(timestamp.hour)}:${two(timestamp.minute)}:${two(timestamp.second)}'; } String pokeHistoryLine(AppL10n l10n, ChatEntry entry) { final time = chatTimeLabel(entry.timestamp); final peerName = entry.senderName.isNotEmpty ? entry.senderName : 'Unknown'; final message = entry.message.trim(); if (entry.isSelf) { if (message.isEmpty) { return l10n.pokeHistorySelfNoMessage(time, peerName); } return l10n.pokeHistorySelfWithMessage(time, peerName, message); } if (message.isEmpty) { return l10n.pokeHistoryIncomingNoMessage(time, peerName); } return l10n.pokeHistoryIncomingWithMessage(time, peerName, message); } /// Resolve the best chat target to show when opening the chat page. /// /// Returns null to show the chat hub. rust.BridgeMessageTarget? resolveInitialChatTarget({ required List messages, required BigInt? currentVoiceChannelId, }) { for (final message in messages.reversed) { if (message.isPrivate) return message.target; } if (currentVoiceChannelId != null) { return const rust.BridgeMessageTarget.channel(); } return null; } class ChatClientGroups { const ChatClientGroups({ required this.clientsByChannel, required this.ungroupedClients, }); final Map> clientsByChannel; final List ungroupedClients; } class ChatClientPickerGroup { const ChatClientPickerGroup({ required this.channelName, required this.clients, }); final String channelName; final List clients; } ChatClientGroups groupChatClientsForPicker(rust.BridgeSnapshot snapshot) { final ownId = snapshot.ownClientId; final clients = snapshot.clients .where((client) => client.id != ownId && !client.isServerQuery) .toList() ..sort((a, b) => a.name.compareTo(b.name)); final channelIds = {for (final channel in snapshot.channels) channel.id}; final byChannel = >{}; final ungrouped = []; for (final client in clients) { if (channelIds.contains(client.channel)) { byChannel.putIfAbsent(client.channel, () => []).add(client); } else { ungrouped.add(client); } } return ChatClientGroups( clientsByChannel: byChannel, ungroupedClients: ungrouped, ); } List filterChatClientPickerGroups({ required List channels, required Map> clientsByChannel, required List ungroupedClients, required String query, }) { final normalizedQuery = query.trim().toLowerCase(); bool matches(rust.BridgeClient client) { return normalizedQuery.isEmpty || client.name.toLowerCase().contains(normalizedQuery); } final ungrouped = ungroupedClients.where(matches).toList(); return [ for (final channel in channels) if (clientsByChannel.containsKey(channel.id)) ChatClientPickerGroup( channelName: channelSpacerLabel(channel.name), clients: clientsByChannel[channel.id]!.where(matches).toList(), ), if (ungrouped.isNotEmpty) ChatClientPickerGroup(channelName: 'Other', clients: ungrouped), ].where((group) => group.clients.isNotEmpty).toList(); } String chatTargetTitle( rust.BridgeMessageTarget target, { required String channelName, required String clientName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Server Activity'; case rust.BridgeMessageTarget_Channel(): return channelName.isNotEmpty ? '# $channelName' : 'Channel'; case rust.BridgeMessageTarget_Client(): return clientName.isNotEmpty ? clientName : 'Direct Message'; case rust.BridgeMessageTarget_Poke(): return clientName.isNotEmpty ? 'Poke: $clientName' : 'Poke'; } } String chatEmptyTitle(rust.BridgeMessageTarget target) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'No server activity yet'; case rust.BridgeMessageTarget_Channel(): return 'No channel messages yet'; case rust.BridgeMessageTarget_Client(): return 'No private messages yet'; case rust.BridgeMessageTarget_Poke(): return 'No pokes'; } } String chatEmptyBody( rust.BridgeMessageTarget target, { required String channelName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Joins, leaves, and disconnects will appear here.'; case rust.BridgeMessageTarget_Channel(): if (channelName.isNotEmpty) { return 'Start the conversation in #$channelName.'; } return 'Join a channel to start chatting.'; case rust.BridgeMessageTarget_Client(): return 'Select a user to start a private chat.'; case rust.BridgeMessageTarget_Poke(): return 'Pokes will appear here when someone needs your attention.'; } } String chatInputPlaceholder( rust.BridgeMessageTarget target, { required String channelName, required String clientName, }) { switch (target) { case rust.BridgeMessageTarget_Server(): return 'Message server...'; case rust.BridgeMessageTarget_Channel(): return 'Message #$channelName...'; case rust.BridgeMessageTarget_Client(): return 'Message $clientName...'; case rust.BridgeMessageTarget_Poke(): return 'Poke message...'; } } bool canSendToChatTarget( rust.BridgeMessageTarget target, BigInt? currentChannelId, ) { switch (target) { case rust.BridgeMessageTarget_Channel(): return currentChannelId != null; default: return true; } } String? chatSendBlockedReason( rust.BridgeMessageTarget target, BigInt? currentChannelId, ) { switch (target) { case rust.BridgeMessageTarget_Channel(): if (currentChannelId == null) { return 'Join a channel to send messages.'; } return null; default: return null; } } /// Chat and activity hub page. class ChatPage extends StatefulWidget { /// Construct a chat page. const ChatPage({ super.key, required this.messages, required this.snapshot, this.initialTarget, this.initialClientName = '', this.onTs3ServerLink, }); /// Backing message list. The detail view appends self-sent messages. final List messages; /// Latest TeamSpeak snapshot. final rust.BridgeSnapshot snapshot; /// Non-null to open a specific target directly; null for hub. final rust.BridgeMessageTarget? initialTarget; /// Initial selected client name for direct-message or poke targets. final String initialClientName; /// Handle TeamSpeak server links embedded in chat messages. final Ts3ServerLinkHandler? onTs3ServerLink; @override State createState() => _ChatPageState(); } class _ChatPageState extends State { late rust.BridgeMessageTarget _selectedTarget; String _selectedClientName = ''; final Set _closedPrivateChats = {}; @override void initState() { super.initState(); _selectedTarget = widget.initialTarget ?? resolveInitialChatTarget( messages: widget.messages, currentVoiceChannelId: _currentChannelId, ) ?? const rust.BridgeMessageTarget.server(); _selectedClientName = widget.initialClientName; } BigInt? get _currentChannelId { return ownClientSnapshotState(widget.snapshot)?.channelId; } @override Widget build(BuildContext context) { final currentChannelId = _currentChannelId; final channelName = snapshotChannelName(widget.snapshot, currentChannelId); return Scaffold( appBar: AppBar( title: Text('Chat — ${widget.snapshot.serverName}'), actions: [ IconButton( tooltip: 'Close chat', icon: const Icon(Icons.close), onPressed: _selectedPrivateClientId == null ? null : _closeSelectedPrivateChat, ), ], ), body: Row( children: [ _ChatSidebar( selectedTarget: _selectedTarget, privateChats: _privateChats, onSelect: _selectTarget, onNewPrivateChat: () => _pickClient((id, name) { _closedPrivateChats.remove(id); _selectTarget(rust.BridgeMessageTarget.client(id), name: name); }), ), const VerticalDivider(width: 1), Expanded( child: _ChatDetailView( target: _selectedTarget, clientName: _selectedClientName, snapshot: widget.snapshot, messages: widget.messages, currentChannelId: currentChannelId, channelName: channelName, onTs3ServerLink: widget.onTs3ServerLink, ), ), ], ), ); } BigInt? get _selectedPrivateClientId { final target = _selectedTarget; return target is rust.BridgeMessageTarget_Client ? target.field0 : null; } List<_PrivateChatItem> get _privateChats { final chats = {}; for (final message in widget.messages) { final target = message.target; if (target is! rust.BridgeMessageTarget_Client) continue; final id = target.field0; if (_closedPrivateChats.contains(id)) continue; final existing = chats[id]; final name = existing?.name.isNotEmpty == true ? existing!.name : _privateChatName(id, message.senderName); chats[id] = _PrivateChatItem(id: id, name: name); } final selectedId = _selectedPrivateClientId; if (selectedId != null && !_closedPrivateChats.contains(selectedId)) { chats.putIfAbsent( selectedId, () => _PrivateChatItem( id: selectedId, name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct', ), ); } return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name)); } String _privateChatName(BigInt id, String fallback) { for (final client in widget.snapshot.clients) { if (client.id == id && client.name.isNotEmpty) return client.name; } return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct'; } void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) { setState(() { _selectedTarget = target; _selectedClientName = name; }); } void _closeSelectedPrivateChat() { final id = _selectedPrivateClientId; if (id == null) return; setState(() { _closedPrivateChats.add(id); _selectedTarget = _currentChannelId != null ? const rust.BridgeMessageTarget.channel() : const rust.BridgeMessageTarget.server(); _selectedClientName = ''; }); } void _pickClient(void Function(BigInt id, String name) cb) { final groups = groupChatClientsForPicker(widget.snapshot); showDialog( context: context, builder: (ctx) => _ClientPickerDialog( channels: widget.snapshot.channels, byChannel: groups.clientsByChannel, ungrouped: groups.ungroupedClients, onSelected: (c) { cb(c.id, c.name); Navigator.pop(ctx); }, ), ); } } class _PrivateChatItem { const _PrivateChatItem({required this.id, required this.name}); final BigInt id; final String name; } class _ChatSidebar extends StatelessWidget { const _ChatSidebar({ required this.selectedTarget, required this.privateChats, required this.onSelect, required this.onNewPrivateChat, }); final rust.BridgeMessageTarget selectedTarget; final List<_PrivateChatItem> privateChats; final void Function(rust.BridgeMessageTarget target, {String name}) onSelect; final VoidCallback onNewPrivateChat; @override Widget build(BuildContext context) { return SizedBox( width: 148, child: Column( children: [ _ChatSidebarItem( icon: Icons.dns_outlined, label: 'Server', selected: selectedTarget is rust.BridgeMessageTarget_Server, onTap: () => onSelect(const rust.BridgeMessageTarget.server()), ), _ChatSidebarItem( icon: Icons.tag, label: 'Channel', selected: selectedTarget is rust.BridgeMessageTarget_Channel, onTap: () => onSelect(const rust.BridgeMessageTarget.channel()), ), const Divider(height: 1), Expanded( child: ListView.builder( padding: EdgeInsets.zero, itemCount: privateChats.length, itemBuilder: (context, index) { final chat = privateChats[index]; final selected = switch (selectedTarget) { rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id, _ => false, }; return _ChatSidebarItem( icon: Icons.person_outline, label: chat.name.isNotEmpty ? chat.name : 'Direct', selected: selected, onTap: () => onSelect( rust.BridgeMessageTarget.client(chat.id), name: chat.name, ), ); }, ), ), const Divider(height: 1), Padding( padding: const EdgeInsets.all(8), child: IconButton.filledTonal( tooltip: 'New private chat', icon: const Icon(Icons.add), onPressed: onNewPrivateChat, ), ), ], ), ); } } class _ChatSidebarItem extends StatelessWidget { const _ChatSidebarItem({ required this.icon, required this.label, required this.selected, required this.onTap, }); final IconData icon; final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final bg = selected ? theme.colorScheme.primaryContainer : Colors.transparent; final fg = selected ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurface; return Material( color: bg, child: InkWell( onTap: onTap, child: SizedBox( height: 44, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( children: [ Icon(icon, size: 18, color: fg), const SizedBox(width: 8), Expanded( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: fg), ), ), ], ), ), ), ), ); } } class _ClientPickerDialog extends StatefulWidget { const _ClientPickerDialog({ required this.channels, required this.byChannel, required this.ungrouped, required this.onSelected, }); final List channels; final Map> byChannel; final List ungrouped; final void Function(rust.BridgeClient c) onSelected; @override State<_ClientPickerDialog> createState() => _ClientPickerDialogState(); } class _ClientPickerDialogState extends State<_ClientPickerDialog> { final _searchCtl = TextEditingController(); String _query = ''; @override void dispose() { _searchCtl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final groups = filterChatClientPickerGroups( channels: widget.channels, clientsByChannel: widget.byChannel, ungroupedClients: widget.ungrouped, query: _query, ); return Dialog( child: SizedBox( width: 360, height: 520, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), child: TextField( controller: _searchCtl, decoration: const InputDecoration( hintText: 'Search clients...', prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), isDense: true, ), onChanged: (v) => setState(() => _query = v), ), ), Expanded( child: ListView( children: [ for (final group in groups) _ChannelGroup( channelName: group.channelName, clients: group.clients, onSelected: widget.onSelected, ), ], ), ), ], ), ), ); } } class _ChannelGroup extends StatelessWidget { const _ChannelGroup({ required this.channelName, required this.clients, required this.onSelected, }); final String channelName; final List clients; final void Function(rust.BridgeClient) onSelected; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (clients.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( channelName, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), for (final c in clients) ListTile( leading: CircleAvatar( radius: 12, child: Text( c.name.isNotEmpty ? c.name[0].toUpperCase() : '?', style: const TextStyle(fontSize: 10), ), ), title: Text(c.name), onTap: () => onSelected(c), ), ], ); } } class _ChatDetailView extends StatefulWidget { const _ChatDetailView({ required this.target, required this.clientName, required this.snapshot, required this.messages, required this.currentChannelId, required this.channelName, this.onTs3ServerLink, }); final rust.BridgeMessageTarget target; final String clientName; final rust.BridgeSnapshot snapshot; final List messages; final BigInt? currentChannelId; final String channelName; final Ts3ServerLinkHandler? onTs3ServerLink; @override State<_ChatDetailView> createState() => _ChatDetailViewState(); } class _ChatDetailViewState extends State<_ChatDetailView> { final _textCtl = TextEditingController(); final _scrollCtl = ScrollController(); Iterable get _filtered { if (widget.target is rust.BridgeMessageTarget_Channel) { return widget.messages.where( (m) => m.target == widget.target || m.isPoke, ); } return widget.messages.where((m) => m.target == widget.target); } bool get _canSend => canSendToChatTarget(widget.target, widget.currentChannelId); String? get _blockedReason => chatSendBlockedReason(widget.target, widget.currentChannelId); String get _title => chatTargetTitle( widget.target, channelName: widget.channelName, clientName: widget.clientName, ); @override void dispose() { _textCtl.dispose(); _scrollCtl.dispose(); super.dispose(); } void _send() { final text = _textCtl.text.trim(); if (text.isEmpty || !_canSend) return; _textCtl.clear(); unawaited(rust.sendChatMessage(message: text, target: widget.target)); final ownId = widget.snapshot.ownClientId; setState(() { widget.messages.add( ChatEntry( senderId: ownId, senderName: widget.target is rust.BridgeMessageTarget_Poke ? widget.clientName : 'You', message: text, target: widget.target, isSelf: true, timestamp: DateTime.now(), ), ); if (widget.messages.length > 200) { widget.messages.removeRange(0, widget.messages.length - 200); } }); _scrollToBottom(); } void _scrollToBottom() { if (_scrollCtl.hasClients) { _scrollCtl.animateTo( _scrollCtl.position.maxScrollExtent, duration: const Duration(milliseconds: 100), curve: Curves.easeOut, ); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final msgs = _filtered.toList(); final placeholder = chatInputPlaceholder( widget.target, channelName: widget.channelName, clientName: widget.clientName, ); return Column( children: [ Container( height: 48, padding: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.centerLeft, decoration: BoxDecoration( border: Border( bottom: BorderSide(color: theme.colorScheme.outlineVariant), ), ), child: Text(_title, style: theme.textTheme.titleMedium), ), Expanded( child: msgs.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.forum_outlined, size: 48, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(height: 16), Text( chatEmptyTitle(widget.target), style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( chatEmptyBody( widget.target, channelName: widget.channelName, ), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ], ), ), ) : ListView.builder( controller: _scrollCtl, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: msgs.length, itemBuilder: (_, i) => _MessageBubble( entry: msgs[i], onTs3ServerLink: widget.onTs3ServerLink, ), ), ), if (_blockedReason != null) Container( width: double.infinity, padding: const EdgeInsets.all(12), color: theme.colorScheme.surfaceContainerHighest, child: Text( _blockedReason!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ), if (_canSend && _blockedReason == null) Padding( padding: const EdgeInsets.all(8), child: Row( children: [ Expanded( child: TextField( controller: _textCtl, decoration: InputDecoration( hintText: placeholder, border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), ), textInputAction: TextInputAction.send, onSubmitted: (_) => _send(), ), ), const SizedBox(width: 8), IconButton.filled( icon: const Icon(Icons.send), onPressed: _send, tooltip: 'Send', ), ], ), ), ], ); } } class _MessageBubble extends StatelessWidget { const _MessageBubble({required this.entry, this.onTs3ServerLink}); final ChatEntry entry; final Ts3ServerLinkHandler? onTs3ServerLink; @override Widget build(BuildContext context) { final theme = Theme.of(context); if (entry.isPoke) { return _PokeHistoryRow(entry: entry); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ CircleAvatar( radius: 14, child: Text( entry.senderName.isNotEmpty ? entry.senderName[0].toUpperCase() : '?', style: const TextStyle(fontSize: 12), ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.senderName, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 12, color: entry.isSelf ? theme.colorScheme.primary : entry.isPrivate ? theme.colorScheme.tertiary : null, ), ), const SizedBox(height: 2), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: entry.isSelf ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: DefaultTextStyle.merge( style: theme.textTheme.bodyMedium, child: BbCodeText( entry.message, linkTrust: LinkTrustService.instance, onTs3ServerLink: onTs3ServerLink, ), ), ), ], ), ), ], ), ); } } class _PokeHistoryRow extends StatelessWidget { const _PokeHistoryRow({required this.entry}); final ChatEntry entry; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.secondary.withValues(alpha: 0.22), ), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.notifications_none, size: 16, color: theme.colorScheme.onSecondaryContainer, ), const SizedBox(width: 8), Expanded( child: Text( pokeHistoryLine(l10n, entry), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSecondaryContainer, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ], ), ), ), ); } }