Files
chanora/apps/chanora_flutter/lib/widgets/chat_views.dart
T

1548 lines
46 KiB
Dart

import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import '../design/breakpoints.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;
typedef ChatMessageSender =
Future<void> Function({
required String message,
required rust.BridgeMessageTarget target,
});
/// 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<ChatEntry> 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<ChatEntry> 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 = <ChatEntry>[];
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<BigInt, List<rust.BridgeClient>> clientsByChannel;
final List<rust.BridgeClient> ungroupedClients;
}
class ChatClientPickerGroup {
const ChatClientPickerGroup({
required this.channelName,
required this.clients,
});
final String channelName;
final List<rust.BridgeClient> 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 = <BigInt, List<rust.BridgeClient>>{};
final ungrouped = <rust.BridgeClient>[];
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<ChatClientPickerGroup> filterChatClientPickerGroups({
required List<rust.BridgeChannel> channels,
required Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
required List<rust.BridgeClient> 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 optional...';
}
}
bool canSendToChatTarget(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
) {
switch (target) {
case rust.BridgeMessageTarget_Channel():
return currentChannelId != null;
default:
return true;
}
}
bool canSendChatMessage(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
String text,
) {
if (!canSendToChatTarget(target, currentChannelId)) return false;
if (target is rust.BridgeMessageTarget_Poke) return true;
return text.trim().isNotEmpty;
}
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<ChatEntry> messages;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Optional live source for messages while this route stays open.
final List<ChatEntry> 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<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late rust.BridgeMessageTarget _selectedTarget;
String _selectedClientName = '';
final Set<BigInt> _closedPrivateChats = {};
List<ChatEntry> 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 < ChanoraBreakpoints.medium,
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
onNewPrivateChat: () => _pickClient((id, name) {
_closedPrivateChats.remove(id);
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
);
if (constraints.maxWidth < ChanoraBreakpoints.medium) {
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 = <BigInt, _PrivateChatItem>{};
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<rust.BridgeChannel> channels;
final Map<BigInt, List<rust.BridgeClient>> byChannel;
final List<rust.BridgeClient> 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<rust.BridgeClient> 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),
),
],
);
}
}
/// Detail view for a single chat target, including message history and input.
class ChatDetailView extends StatefulWidget {
/// Construct a chat detail view.
const ChatDetailView({
super.key,
required this.target,
required this.clientName,
required this.snapshot,
required this.messages,
required this.currentChannelId,
required this.channelName,
this.onTs3ServerLink,
this.headerTrailing,
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
this.sendChatMessage,
});
/// Chat target displayed by this detail view.
final rust.BridgeMessageTarget target;
/// Client display name for direct-message and poke targets.
final String clientName;
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Backing message list. Self-sent messages are appended here.
final List<ChatEntry> messages;
/// Current voice channel id for channel-chat send gating.
final BigInt? currentChannelId;
/// Current voice channel name for labels and placeholders.
final String channelName;
/// Handle TeamSpeak server links embedded in chat messages.
final Ts3ServerLinkHandler? onTs3ServerLink;
/// Optional widget shown at the trailing edge of the header.
final Widget? headerTrailing;
/// Optional max width for message content.
final double? messageMaxWidth;
/// External draft text to restore when the widget initializes or the target changes.
final String? restoredDraft;
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
/// Sends a chat message. Defaults to the Rust bridge send path.
final ChatMessageSender? sendChatMessage;
@override
State<ChatDetailView> createState() => _ChatDetailViewState();
}
class _ChatDetailViewState extends State<ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
rust.BridgeMessageTarget? _lastRenderedTarget;
Iterable<ChatEntry> 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 initState() {
super.initState();
if (widget.restoredDraft != null && widget.restoredDraft!.isNotEmpty) {
_textCtl.text = widget.restoredDraft!;
}
}
@override
void didUpdateWidget(covariant ChatDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.target != widget.target) {
// Propagate the OUTGOING draft unconditionally, including empty
// text. The empty case is load-bearing: if the user typed text,
// saved it, restored it, then deleted everything, the parent
// map must learn the draft is now empty — otherwise the stale
// entry resurrects on the next target swap.
oldWidget.onDraftChanged?.call(_textCtl.text);
_textCtl.text = widget.restoredDraft ?? '';
_lastRenderedTarget = null;
}
}
@override
void dispose() {
// Same unconditional flush on tear-down. The `isNotEmpty` guard
// here would silently drop "user cleared the field then closed
// the panel" into the same stale-entry bug class as didUpdateWidget.
widget.onDraftChanged?.call(_textCtl.text);
_textCtl.dispose();
_scrollCtl.dispose();
super.dispose();
}
void _send() {
final text = _textCtl.text.trim();
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
return;
}
_textCtl.clear();
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
unawaited(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,
);
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
? 'Poke'
: 'Send';
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: Row(
children: [
Expanded(child: Text(_title, style: theme.textTheme.titleMedium)),
if (widget.headerTrailing != null) widget.headerTrailing!,
],
),
),
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) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: widget.messageMaxWidth ?? double.infinity,
),
child: _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: sendTooltip,
),
],
),
),
],
);
}
}
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 = <InlineSpan>[
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()],
),
),
),
],
),
),
),
);
}
}