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'; const double _chatSidebarTileExtent = 92; const double _chatSidebarCompactTileExtent = 76; const double _chatSidebarCompactHeight = 84; const double _chatMobileBreakpoint = 600; /// 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, this.countsTowardUnread = true, }); /// 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; /// Whether this entry should increment the message unread badge. final bool countsTowardUnread; /// 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; /// True for synthetic or protocol-driven server activity rows. bool get isServerActivity => target is rust.BridgeMessageTarget_Server && !countsTowardUnread; } 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); } enum _ActivityTone { positive, transition, warning, plain, username, channel, adminActor, automationActor, group, } sealed class _ActivitySegment { const _ActivitySegment(this.text, this.tone, {this.bold = false}); final String text; final _ActivityTone tone; final bool bold; } class _ActivityTextSegment extends _ActivitySegment { const _ActivityTextSegment(super.text, super.tone, {super.bold = false}); } List<_ActivitySegment> _activitySegments(String message) { final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList(); if (quotedMatches.isEmpty) { return [_ActivityTextSegment(message, _activityActionTone(message))]; } final segments = <_ActivitySegment>[]; var cursor = 0; for (var i = 0; i < quotedMatches.length; i++) { final match = quotedMatches[i]; if (match.start > cursor) { final plain = message.substring(cursor, match.start); if (plain.isNotEmpty) { segments.add( _ActivityTextSegment(plain, _activityActionTone(plain.trim())), ); } } final quoted = match.group(0)!; segments.add(_activityEntitySegment(quoted, message, i)); cursor = match.end; } if (cursor < message.length) { final tail = message.substring(cursor); if (tail.isNotEmpty) { segments.add( _ActivityTextSegment(tail, _activityActionTone(tail.trim())), ); } } return segments; } _ActivitySegment _activityEntitySegment( String quoted, String fullMessage, int quotedIndex, ) { final lower = fullMessage.toLowerCase(); final after = fullMessage .substring(fullMessage.indexOf(quoted) + quoted.length) .toLowerCase(); final value = quoted.substring(1, quoted.length - 1); if (lower.contains('channel group ')) { if (quotedIndex == 0) { return _ActivityTextSegment(quoted, _ActivityTone.group); } return _activityActorSegment(value, quoted); } if (lower.contains('server group ')) { if (quotedIndex == 0) { return _ActivityTextSegment(quoted, _ActivityTone.group); } return _activityActorSegment(value, quoted); } if (after.startsWith(' connected to channel') || after.startsWith(' switched from channel') || after.startsWith(' disconnected') || after.startsWith(' was moved from channel') || after.startsWith(' is now ')) { return _activityActorSegment(value, quoted); } if (lower.contains('channel ') && (after.startsWith('.') || after.startsWith(' to') || after.startsWith(' from') || after.startsWith(' by') || after.isEmpty)) { return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true); } if (lower.contains('channel ') && (quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) { return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true); } return _activityActorSegment(value, quoted); } _ActivitySegment _activityActorSegment(String value, String quoted) { final lower = value.toLowerCase(); if (lower.contains('auto') || lower.contains('automation') || lower.contains('bot')) { return _ActivityTextSegment( quoted, _ActivityTone.automationActor, bold: true, ); } if (lower.contains('server') || lower.contains('admin') || lower.contains('vigorous pro')) { return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true); } return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true); } _ActivityTone _activityActionTone(String text) { final lower = text.toLowerCase(); if (lower.contains('connected')) return _ActivityTone.positive; if (lower.contains('disconnected') || lower.contains('dropped') || lower.contains('lost') || lower.contains('shutdown')) { return _ActivityTone.warning; } if (lower.contains('switched') || lower.contains('moved') || lower.contains('created') || lower.contains('deleted') || lower.contains('renamed') || lower.contains('assigned') || lower.contains('removed')) { return _ActivityTone.transition; } return _ActivityTone.plain; } Color _activityColor(BuildContext context, _ActivityTone tone) { return switch (tone) { _ActivityTone.positive => const Color(0xFF2F7D57), _ActivityTone.transition => const Color(0xFF5A7394), _ActivityTone.warning => const Color(0xFFC06A2B), _ActivityTone.plain => const Color(0xFF6C7C8F), _ActivityTone.username => const Color(0xFFC64A4A), _ActivityTone.channel => const Color(0xFF3B6EA8), _ActivityTone.adminActor => const Color(0xFF426B9A), _ActivityTone.automationActor => const Color(0xFFB64C4C), _ActivityTone.group => const Color(0xFF8B7A68), }; } /// 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, }) { bool hasServerActivity = false; for (final message in messages.reversed) { if (message.isPrivate) return message.target; if (message.target is rust.BridgeMessageTarget_Server) { hasServerActivity = true; } } if (currentVoiceChannelId != null) { return const rust.BridgeMessageTarget.channel(); } if (hasServerActivity) { return const rust.BridgeMessageTarget.server(); } return null; } /// Build server-activity entries from a fresh snapshot diff. List buildServerActivityEntries({ required rust.BridgeSnapshot previous, required rust.BridgeSnapshot current, DateTime? timestamp, }) { final now = timestamp ?? DateTime.now(); final previousClients = { for (final client in previous.clients) client.id: client, }; final currentClients = { for (final client in current.clients) client.id: client, }; final previousChannels = { for (final channel in previous.channels) channel.id: channel, }; final currentChannels = { for (final channel in current.channels) channel.id: channel, }; final entries = []; String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) { if (client.id == snapshot.ownClientId) return 'You'; final name = client.name.isNotEmpty ? client.name : 'Unknown'; return '"$name"'; } String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) { final name = snapshotChannelName(snapshot, channelId); if (name.isNotEmpty) return 'channel "$name"'; return 'channel ${channelId.toString()}'; } void addEntry(String message) { entries.add( ChatEntry( senderId: BigInt.zero, senderName: 'Server', message: message, target: const rust.BridgeMessageTarget.server(), timestamp: now, countsTowardUnread: false, ), ); } for (final client in current.clients) { if (client.isServerQuery) continue; final previousClient = previousClients[client.id]; if (previousClient == null) { addEntry( '${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}', ); continue; } if (previousClient.channel != client.channel) { final fromChannel = channelLabel(previous, previousClient.channel); final toChannel = channelLabel(current, client.channel); addEntry( '${clientLabel(client, current)} switched from $fromChannel to $toChannel', ); continue; } if (previousClient.name != client.name && client.name.isNotEmpty) { final previousName = previousClient.id == current.ownClientId ? 'You' : (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown'); final currentName = clientLabel(client, current); addEntry('$previousName is now $currentName'); } } for (final client in previous.clients) { if (client.isServerQuery) continue; if (currentClients.containsKey(client.id)) continue; addEntry( '${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}', ); } for (final channel in current.channels) { final previousChannel = previousChannels[channel.id]; if (previousChannel == null) { addEntry('Channel created: ${channelLabel(current, channel.id)}'); continue; } if (previousChannel.name != channel.name) { addEntry('Channel renamed to ${channelLabel(current, channel.id)}'); continue; } if (previousChannel.parent != channel.parent || previousChannel.order != channel.order) { addEntry('Channel moved: ${channelLabel(current, channel.id)}'); } } for (final channel in previous.channels) { if (currentChannels.containsKey(channel.id)) continue; addEntry('Channel deleted: ${channelLabel(previous, channel.id)}'); } return entries; } 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.messagesSource, this.snapshotSource, this.refreshListenable, 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; /// Optional live source for messages while this route stays open. final List Function()? messagesSource; /// Optional live source for snapshots while this route stays open. final rust.BridgeSnapshot Function()? snapshotSource; /// Triggers a rebuild when the backing chat state changes. final Listenable? refreshListenable; /// 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 = {}; List get _messages => widget.messagesSource?.call() ?? widget.messages; rust.BridgeSnapshot get _snapshot => widget.snapshotSource?.call() ?? widget.snapshot; @override void initState() { super.initState(); _selectedTarget = widget.initialTarget ?? resolveInitialChatTarget( messages: _messages, currentVoiceChannelId: _currentChannelId, ) ?? const rust.BridgeMessageTarget.server(); _selectedClientName = widget.initialClientName; } BigInt? get _currentChannelId { return ownClientSnapshotState(_snapshot)?.channelId; } @override Widget build(BuildContext context) { if (widget.refreshListenable != null) { return ListenableBuilder( listenable: widget.refreshListenable!, builder: (context, _) => _buildScaffold(context), ); } return _buildScaffold(context); } Widget _buildScaffold(BuildContext context) { final snapshot = _snapshot; final messages = _messages; final currentChannelId = _currentChannelId; final channelName = snapshotChannelName(snapshot, currentChannelId); final l10n = AppL10n.of(context); final detail = _ChatDetailView( target: _selectedTarget, clientName: _selectedClientName, snapshot: snapshot, messages: messages, currentChannelId: currentChannelId, channelName: channelName, onTs3ServerLink: widget.onTs3ServerLink, ); return Scaffold( appBar: AppBar( title: Text('${l10n.chatAction} - ${snapshot.serverName}'), actions: [ IconButton( tooltip: l10n.chatCloseAction, icon: const Icon(Icons.close), onPressed: _selectedPrivateClientId == null ? null : _closeSelectedPrivateChat, ), ], ), body: LayoutBuilder( builder: (context, constraints) { final sidebar = _ChatSidebar( compact: constraints.maxWidth < _chatMobileBreakpoint, selectedTarget: _selectedTarget, privateChats: _privateChats, onSelect: _selectTarget, onNewPrivateChat: () => _pickClient((id, name) { _closedPrivateChats.remove(id); _selectTarget(rust.BridgeMessageTarget.client(id), name: name); }), ); if (constraints.maxWidth < _chatMobileBreakpoint) { return Column( children: [ sidebar, const Divider(height: 1), Expanded(child: detail), ], ); } return Row( children: [ sidebar, const VerticalDivider(width: 1), Expanded(child: detail), ], ); }, ), ); } 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 _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 _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.compact, required this.selectedTarget, required this.privateChats, required this.onSelect, required this.onNewPrivateChat, }); final bool compact; 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) { final l10n = AppL10n.of(context); final fixedItems = [ _ChatSidebarItem( compact: compact, icon: Icons.dns_outlined, label: 'Server', selected: selectedTarget is rust.BridgeMessageTarget_Server, onTap: () => onSelect(const rust.BridgeMessageTarget.server()), ), _ChatSidebarItem( compact: compact, icon: Icons.tag, label: 'Channel', selected: selectedTarget is rust.BridgeMessageTarget_Channel, onTap: () => onSelect(const rust.BridgeMessageTarget.channel()), ), ]; final privateItems = [ for (final chat in privateChats) _ChatSidebarItem( compact: compact, icon: Icons.person_outline, label: chat.name.isNotEmpty ? chat.name : 'Direct', selected: switch (selectedTarget) { rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id, _ => false, }, onTap: () => onSelect( rust.BridgeMessageTarget.client(chat.id), name: chat.name, ), ), ]; final addButton = Padding( padding: EdgeInsets.all(compact ? 4 : 8), child: IconButton.filledTonal( tooltip: l10n.chatNewPrivateAction, icon: const Icon(Icons.add), onPressed: onNewPrivateChat, ), ); final compactDivider = ColoredBox( color: Theme.of(context).dividerColor, child: const SizedBox(width: 1, height: double.infinity), ); if (compact) { return SizedBox( height: _chatSidebarCompactHeight, child: Row( children: [ ...fixedItems, compactDivider, Expanded( child: ListView( scrollDirection: Axis.horizontal, padding: EdgeInsets.zero, children: privateItems, ), ), compactDivider, addButton, ], ), ); } return SizedBox( width: _chatSidebarTileExtent, child: Column( children: [ ...fixedItems, const Divider(height: 1), Expanded( child: ListView(padding: EdgeInsets.zero, children: privateItems), ), const Divider(height: 1), addButton, ], ), ); } } class _ChatSidebarItem extends StatelessWidget { const _ChatSidebarItem({ required this.compact, required this.icon, required this.label, required this.selected, required this.onTap, }); final bool compact; final IconData icon; final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final fg = selected ? colorScheme.onSecondaryContainer : colorScheme.onSurface; final tileExtent = compact ? _chatSidebarCompactTileExtent : _chatSidebarTileExtent; return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Ink( width: tileExtent, height: compact ? 72 : tileExtent, decoration: BoxDecoration( color: selected ? colorScheme.secondaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(8), ), child: Padding( padding: EdgeInsets.symmetric( horizontal: compact ? 6 : 8, vertical: compact ? 8 : 10, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 24, height: 24, child: Stack( clipBehavior: Clip.none, alignment: Alignment.center, children: [Icon(icon, size: 18, color: fg)], ), ), const SizedBox(height: 8), Text( label, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: theme.textTheme.labelSmall?.copyWith( color: fg, height: 1.1, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, ), ), ], ), ), ), ), ); } } 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 l10n = AppL10n.of(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: InputDecoration( hintText: l10n.chatSearchClientsHint, prefixIcon: const Icon(Icons.search), border: const 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(); int _lastRenderedMessageCount = -1; rust.BridgeMessageTarget? _lastRenderedTarget; 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, ); } } void _scheduleScrollIfNeeded(int messageCount) { final targetChanged = _lastRenderedTarget != widget.target; final countChanged = _lastRenderedMessageCount != messageCount; _lastRenderedTarget = widget.target; _lastRenderedMessageCount = messageCount; if (!targetChanged && !countChanged) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _scrollToBottom(); }); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final msgs = _filtered.toList(); _scheduleScrollIfNeeded(msgs.length); 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) { if (entry.isPoke) { return _PokeHistoryRow(entry: entry); } if (entry.isServerActivity) { return _ServerActivityRow(entry: entry); } final theme = Theme.of(context); 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 _ServerActivityRow extends StatelessWidget { const _ServerActivityRow({required this.entry}); final ChatEntry entry; @override Widget build(BuildContext context) { final theme = Theme.of(context); final timestampStyle = theme.textTheme.bodySmall?.copyWith( color: const Color(0xFF8A98A8), fontStyle: FontStyle.italic, fontWeight: FontWeight.w400, letterSpacing: 0.15, fontFeatures: const [FontFeature.tabularFigures()], ); final baseStyle = theme.textTheme.bodySmall?.copyWith( color: const Color(0xFF6C7C8F), fontWeight: FontWeight.w400, fontSize: 13, height: 1.45, letterSpacing: 0.05, ); final spans = [ TextSpan( text: '<${chatTimeLabel(entry.timestamp)}> ', style: timestampStyle, ), ]; for (final segment in _activitySegments(entry.message)) { spans.add( TextSpan( text: segment.text, style: baseStyle?.copyWith( color: _activityColor(context, segment.tone), fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400, ), ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3), child: DecoratedBox( decoration: BoxDecoration( color: const Color(0xFFF7F9FC), borderRadius: BorderRadius.circular(10), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text.rich(TextSpan(children: spans)), ), ), ); } } 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()], ), ), ), ], ), ), ), ); } }