Handle pokes outside chat tabs

This commit is contained in:
Edison Jwa
2026-05-23 05:25:36 +09:00
parent 738b274748
commit 7e28791ec2
10 changed files with 2567 additions and 1347 deletions
+55 -1
View File
@@ -14,7 +14,11 @@
"fieldDisplayName": "Display name",
"connectAction": "Connect",
"disconnectAction": "Disconnect",
"disconnectAction": "Leave server",
"disconnectConfirmTitle": "Leave server?",
"disconnectConfirmBody": "You will disconnect from the current server.",
"disconnectConfirmCancel": "Stay",
"disconnectConfirmAction": "Leave",
"refreshAction": "Refresh",
"diagnosticsAction": "Diagnostics",
"diagnosticsSaveAction": "Save export",
@@ -177,5 +181,55 @@
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"settingsAction": "Settings",
"appSettingsTitle": "Settings",
"pokeAlertsTitle": "Poke alerts",
"pokeAlertsDescription": "Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.",
"pokeDialogTitle": "You were poked",
"pokeDialogCloseAction": "Close",
"pokeDialogIncomingNoMessage": "{time} {sender} pokes you",
"@pokeDialogIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} pokes you with a message",
"@pokeDialogIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
"@pokeHistorySelfNoMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" }
}
},
"pokeHistorySelfWithMessage": "<{time}> You poked \"{target}\" with message: {message}",
"@pokeHistorySelfWithMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistoryIncomingNoMessage": "<{time}> \"{sender}\" pokes you",
"@pokeHistoryIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistoryIncomingWithMessage": "<{time}> \"{sender}\" pokes you: {message}",
"@pokeHistoryIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"permissionDenied": "Permission Denied"
}
+55 -1
View File
@@ -13,7 +13,11 @@
"fieldDisplayName": "显示名称",
"connectAction": "连接",
"disconnectAction": "断开连接",
"disconnectAction": "离开服务器",
"disconnectConfirmTitle": "离开服务器?",
"disconnectConfirmBody": "你将从当前服务器断开连接。",
"disconnectConfirmCancel": "留下",
"disconnectConfirmAction": "离开",
"refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
"diagnosticsSaveAction": "保存导出",
@@ -131,5 +135,55 @@
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"settingsAction": "设置",
"appSettingsTitle": "设置",
"pokeAlertsTitle": "戳一戳提醒",
"pokeAlertsDescription": "收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。",
"pokeDialogTitle": "你被戳了一下",
"pokeDialogCloseAction": "关闭",
"pokeDialogIncomingNoMessage": "{time} {sender} 戳了你一下",
"@pokeDialogIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} 戳了你一下并附带消息",
"@pokeDialogIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
"@pokeHistorySelfNoMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" }
}
},
"pokeHistorySelfWithMessage": "<{time}> 你戳了“{target}”一下,消息:{message}",
"@pokeHistorySelfWithMessage": {
"placeholders": {
"time": { "type": "String" },
"target": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistoryIncomingNoMessage": "<{time}> “{sender}”戳了你一下",
"@pokeHistoryIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeHistoryIncomingWithMessage": "<{time}> “{sender}”戳了你一下:{message}",
"@pokeHistoryIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"permissionDenied": "权限被拒绝"
}
@@ -154,9 +154,33 @@ abstract class AppL10n {
/// No description provided for @disconnectAction.
///
/// In en, this message translates to:
/// **'Disconnect'**
/// **'Leave server'**
String get disconnectAction;
/// No description provided for @disconnectConfirmTitle.
///
/// In en, this message translates to:
/// **'Leave server?'**
String get disconnectConfirmTitle;
/// No description provided for @disconnectConfirmBody.
///
/// In en, this message translates to:
/// **'You will disconnect from the current server.'**
String get disconnectConfirmBody;
/// No description provided for @disconnectConfirmCancel.
///
/// In en, this message translates to:
/// **'Stay'**
String get disconnectConfirmCancel;
/// No description provided for @disconnectConfirmAction.
///
/// In en, this message translates to:
/// **'Leave'**
String get disconnectConfirmAction;
/// No description provided for @refreshAction.
///
/// In en, this message translates to:
@@ -799,6 +823,82 @@ abstract class AppL10n {
/// **'Audio resuming'**
String get iosAudioResuming;
/// No description provided for @settingsAction.
///
/// In en, this message translates to:
/// **'Settings'**
String get settingsAction;
/// No description provided for @appSettingsTitle.
///
/// In en, this message translates to:
/// **'Settings'**
String get appSettingsTitle;
/// No description provided for @pokeAlertsTitle.
///
/// In en, this message translates to:
/// **'Poke alerts'**
String get pokeAlertsTitle;
/// No description provided for @pokeAlertsDescription.
///
/// In en, this message translates to:
/// **'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'**
String get pokeAlertsDescription;
/// No description provided for @pokeDialogTitle.
///
/// In en, this message translates to:
/// **'You were poked'**
String get pokeDialogTitle;
/// No description provided for @pokeDialogCloseAction.
///
/// In en, this message translates to:
/// **'Close'**
String get pokeDialogCloseAction;
/// No description provided for @pokeDialogIncomingNoMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you'**
String pokeDialogIncomingNoMessage(String time, String sender);
/// No description provided for @pokeDialogIncomingWithMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you with a message'**
String pokeDialogIncomingWithMessage(String time, String sender);
/// No description provided for @pokeHistorySelfNoMessage.
///
/// In en, this message translates to:
/// **'<{time}> You poked \"{target}\".'**
String pokeHistorySelfNoMessage(String time, String target);
/// No description provided for @pokeHistorySelfWithMessage.
///
/// In en, this message translates to:
/// **'<{time}> You poked \"{target}\" with message: {message}'**
String pokeHistorySelfWithMessage(String time, String target, String message);
/// No description provided for @pokeHistoryIncomingNoMessage.
///
/// In en, this message translates to:
/// **'<{time}> \"{sender}\" pokes you'**
String pokeHistoryIncomingNoMessage(String time, String sender);
/// No description provided for @pokeHistoryIncomingWithMessage.
///
/// In en, this message translates to:
/// **'<{time}> \"{sender}\" pokes you: {message}'**
String pokeHistoryIncomingWithMessage(
String time,
String sender,
String message,
);
/// No description provided for @permissionDenied.
///
/// In en, this message translates to:
@@ -38,7 +38,20 @@ class AppL10nEn extends AppL10n {
String get connectAction => 'Connect';
@override
String get disconnectAction => 'Disconnect';
String get disconnectAction => 'Leave server';
@override
String get disconnectConfirmTitle => 'Leave server?';
@override
String get disconnectConfirmBody =>
'You will disconnect from the current server.';
@override
String get disconnectConfirmCancel => 'Stay';
@override
String get disconnectConfirmAction => 'Leave';
@override
String get refreshAction => 'Refresh';
@@ -402,6 +415,63 @@ class AppL10nEn extends AppL10n {
@override
String get iosAudioResuming => 'Audio resuming';
@override
String get settingsAction => 'Settings';
@override
String get appSettingsTitle => 'Settings';
@override
String get pokeAlertsTitle => 'Poke alerts';
@override
String get pokeAlertsDescription =>
'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.';
@override
String get pokeDialogTitle => 'You were poked';
@override
String get pokeDialogCloseAction => 'Close';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender pokes you';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender pokes you with a message';
}
@override
String pokeHistorySelfNoMessage(String time, String target) {
return '<$time> You poked \"$target\".';
}
@override
String pokeHistorySelfWithMessage(
String time,
String target,
String message,
) {
return '<$time> You poked \"$target\" with message: $message';
}
@override
String pokeHistoryIncomingNoMessage(String time, String sender) {
return '<$time> \"$sender\" pokes you';
}
@override
String pokeHistoryIncomingWithMessage(
String time,
String sender,
String message,
) {
return '<$time> \"$sender\" pokes you: $message';
}
@override
String get permissionDenied => 'Permission Denied';
}
@@ -36,7 +36,19 @@ class AppL10nZh extends AppL10n {
String get connectAction => '连接';
@override
String get disconnectAction => '断开连接';
String get disconnectAction => '离开服务器';
@override
String get disconnectConfirmTitle => '离开服务器?';
@override
String get disconnectConfirmBody => '你将从当前服务器断开连接。';
@override
String get disconnectConfirmCancel => '留下';
@override
String get disconnectConfirmAction => '离开';
@override
String get refreshAction => '刷新';
@@ -393,6 +405,62 @@ class AppL10nZh extends AppL10n {
@override
String get iosAudioResuming => '音频正在恢复';
@override
String get settingsAction => '设置';
@override
String get appSettingsTitle => '设置';
@override
String get pokeAlertsTitle => '戳一戳提醒';
@override
String get pokeAlertsDescription => '收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。';
@override
String get pokeDialogTitle => '你被戳了一下';
@override
String get pokeDialogCloseAction => '关闭';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender 戳了你一下';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender 戳了你一下并附带消息';
}
@override
String pokeHistorySelfNoMessage(String time, String target) {
return '<$time> 你戳了“$target”一下。';
}
@override
String pokeHistorySelfWithMessage(
String time,
String target,
String message,
) {
return '<$time> 你戳了“$target”一下,消息:$message';
}
@override
String pokeHistoryIncomingNoMessage(String time, String sender) {
return '<$time> “$sender”戳了你一下';
}
@override
String pokeHistoryIncomingWithMessage(
String time,
String sender,
String message,
) {
return '<$time> “$sender”戳了你一下:$message';
}
@override
String get permissionDenied => '权限被拒绝';
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import 'package:shared_preferences/shared_preferences.dart';
class UiSettings {
const UiSettings({
this.host = '',
this.nickname = '',
this.showPokeDialogs = true,
});
final String host;
final String nickname;
final bool showPokeDialogs;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _showPokeDialogsKey = 'ui.show_poke_dialogs';
static const _permissionsExplainedKey = 'perms_explained';
const UiPreferencesService();
Future<UiSettings> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true,
);
}
Future<void> saveSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
final prefs = await SharedPreferences.getInstance();
if (host != null) await prefs.setString(_hostKey, host);
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
if (showPokeDialogs != null) {
await prefs.setBool(_showPokeDialogsKey, showPokeDialogs);
}
}
Future<bool> hasExplainedPermissions() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_permissionsExplainedKey) ?? false;
}
Future<void> markPermissionsExplained() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_permissionsExplainedKey, true);
}
}
@@ -0,0 +1,910 @@
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<ChatEntry> 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<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...';
}
}
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<ChatEntry> 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<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
rust.BridgeMessageTarget? _detailTarget;
String _selectedClientName = '';
@override
void initState() {
super.initState();
_detailTarget = widget.initialTarget;
_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);
if (_detailTarget != null) {
return _ChatDetailView(
target: _detailTarget!,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onBack: () => setState(() => _detailTarget = null),
onTs3ServerLink: widget.onTs3ServerLink,
);
}
return _ChatHub(
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onOpen: (t, {String name = ''}) {
setState(() {
_detailTarget = t;
_selectedClientName = name;
});
},
onPickClient: (fn) => _pickClient(fn),
);
}
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 _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 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<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),
),
],
);
}
}
class _ChatHub extends StatelessWidget {
const _ChatHub({
required this.snapshot,
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onOpen,
required this.onPickClient,
});
final rust.BridgeSnapshot snapshot;
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final void Function(rust.BridgeMessageTarget t, {String name}) onOpen;
final void Function(void Function(BigInt id, String name) cb) onPickClient;
Iterable<ChatEntry> _of(rust.BridgeMessageTarget t) =>
messages.where((m) => m.target == t);
@override
Widget build(BuildContext context) {
final channelMsgs = _of(const rust.BridgeMessageTarget.channel());
final privateMsgs = messages.where((m) => m.isPrivate);
final serverMsgs = _of(const rust.BridgeMessageTarget.server());
return Scaffold(
appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_HubCard(
icon: Icons.tag,
title: channelName.isNotEmpty
? '# $channelName'
: 'Current Channel',
subtitle: channelMsgs.isNotEmpty
? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}'
: 'No messages yet',
detail: currentChannelId != null
? 'You can send messages here'
: 'Join a channel to send messages',
onTap: () => onOpen(const rust.BridgeMessageTarget.channel()),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.person_outline,
title: 'Direct Messages',
subtitle: privateMsgs.isNotEmpty
? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}'
: 'No private messages',
detail: 'Select a user to start a private chat.',
onTap: () => onPickClient(
(id, name) =>
onOpen(rust.BridgeMessageTarget.client(id), name: name),
),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.list_alt_outlined,
title: 'Server Activity',
subtitle: serverMsgs.isNotEmpty
? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}'
: 'No server activity',
detail: serverMsgs.isNotEmpty
? 'Server messages and events appear here.'
: 'Server-wide messages will appear here.',
onTap: () => onOpen(const rust.BridgeMessageTarget.server()),
),
],
),
);
}
}
class _HubCard extends StatelessWidget {
const _HubCard({
required this.icon,
required this.title,
required this.subtitle,
required this.detail,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: Icon(icon, color: theme.colorScheme.primary),
title: Text(title, style: theme.textTheme.titleSmall),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodyMedium),
Text(
detail,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _ChatDetailView extends StatefulWidget {
const _ChatDetailView({
required this.target,
required this.clientName,
required this.snapshot,
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onBack,
this.onTs3ServerLink,
});
final rust.BridgeMessageTarget target;
final String clientName;
final rust.BridgeSnapshot snapshot;
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final VoidCallback onBack;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
State<_ChatDetailView> createState() => _ChatDetailViewState();
}
class _ChatDetailViewState extends State<_ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
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 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 Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(_title),
actions: [
TextButton(onPressed: widget.onBack, child: const Text('Activity')),
],
),
body: Column(
children: [
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()],
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/ui_preferences_service.dart';
void main() {
late UiPreferencesService service;
setUp(() {
SharedPreferences.setMockInitialValues({});
service = const UiPreferencesService();
});
test('loads empty settings when preferences are unset', () async {
final settings = await service.loadSettings();
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
expect(settings.showPokeDialogs, isTrue);
});
test('saves and loads host and nickname independently', () async {
await service.saveSettings(host: 'example.com');
var settings = await service.loadSettings();
expect(settings.host, 'example.com');
expect(settings.nickname, isEmpty);
await service.saveSettings(nickname: 'Chanora');
settings = await service.loadSettings();
expect(settings.host, 'example.com');
expect(settings.nickname, 'Chanora');
});
test('saves poke dialog preference independently', () async {
await service.saveSettings(showPokeDialogs: false);
var settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
await service.saveSettings(host: 'example.com');
settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, 'example.com');
});
test('tracks permission explanation flag', () async {
expect(await service.hasExplainedPermissions(), isFalse);
await service.markPermissionsExplained();
expect(await service.hasExplainedPermissions(), isTrue);
});
}
@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/chat_views.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
ChatEntry entry(rust.BridgeMessageTarget target) {
return ChatEntry(
senderId: BigInt.one,
senderName: 'Sender',
message: 'Message',
target: target,
);
}
rust.BridgeChannel channel(BigInt id, String name) {
return rust.BridgeChannel(
id: id,
parent: BigInt.zero,
name: name,
order: 0,
hasPassword: false,
neededTalkPower: 0,
);
}
rust.BridgeClient client({
required BigInt id,
required String name,
required BigInt channelId,
bool isServerQuery = false,
}) {
return rust.BridgeClient(
id: id,
channel: channelId,
name: name,
inputMuted: false,
outputMuted: false,
isSpeaking: false,
isServerQuery: isServerQuery,
talkPower: 0,
talkPowerGranted: false,
);
}
rust.BridgeSnapshot snapshot({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
}) {
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: BigInt.one,
);
}
test('initial chat target opens latest private conversation first', () {
final target = resolveInitialChatTarget(
messages: [
entry(rust.BridgeMessageTarget.client(BigInt.from(1))),
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(rust.BridgeMessageTarget.client(BigInt.from(3))),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, rust.BridgeMessageTarget.client(BigInt.from(3)));
});
test('initial chat target ignores pokes and falls back to channel', () {
final target = resolveInitialChatTarget(
messages: [
entry(const rust.BridgeMessageTarget.server()),
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(rust.BridgeMessageTarget.poke(BigInt.from(4))),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, const rust.BridgeMessageTarget.channel());
});
test('initial chat target falls back to channel when connected to voice', () {
final target = resolveInitialChatTarget(
messages: [
entry(const rust.BridgeMessageTarget.server()),
entry(const rust.BridgeMessageTarget.channel()),
],
currentVoiceChannelId: BigInt.from(9),
);
expect(target, const rust.BridgeMessageTarget.channel());
});
test('initial chat target falls back to hub when nothing is active', () {
final target = resolveInitialChatTarget(
messages: const [],
currentVoiceChannelId: null,
);
expect(target, isNull);
});
test(
'groups chat picker clients by channel and excludes own/query clients',
() {
final groups = groupChatClientsForPicker(
snapshot(
channels: [channel(BigInt.from(10), 'Lobby')],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
client(
id: BigInt.from(2),
name: 'Zulu',
channelId: BigInt.from(10),
),
client(
id: BigInt.from(3),
name: 'Alpha',
channelId: BigInt.from(10),
),
client(
id: BigInt.from(4),
name: 'Query',
channelId: BigInt.from(10),
isServerQuery: true,
),
client(
id: BigInt.from(5),
name: 'Orphan',
channelId: BigInt.from(99),
),
],
),
);
expect(
groups.clientsByChannel[BigInt.from(10)]?.map((client) => client.name),
['Alpha', 'Zulu'],
);
expect(groups.ungroupedClients.map((client) => client.name), ['Orphan']);
},
);
test('filters chat picker groups by client name case-insensitively', () {
final groups = filterChatClientPickerGroups(
channels: [
channel(BigInt.from(10), 'Lobby'),
channel(BigInt.from(20), 'Games'),
],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
BigInt.from(20): [
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Alfred', channelId: BigInt.from(99)),
],
query: 'AL',
);
expect(groups.map((group) => group.channelName), ['Lobby', 'Other']);
expect(
groups.expand((group) => group.clients).map((client) => client.name),
['Alpha', 'Alfred'],
);
});
test('keeps all chat picker groups for blank queries', () {
final groups = filterChatClientPickerGroups(
channels: [channel(BigInt.from(10), 'Lobby')],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(10)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)),
],
query: ' ',
);
expect(groups.map((group) => group.channelName), ['Lobby', 'Other']);
expect(groups.first.clients.map((client) => client.name), [
'Alpha',
'Bravo',
]);
expect(groups.last.clients.map((client) => client.name), ['Orphan']);
});
test('omits empty chat picker groups after filtering', () {
final groups = filterChatClientPickerGroups(
channels: [
channel(BigInt.from(10), 'Lobby'),
channel(BigInt.from(20), 'Games'),
],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
BigInt.from(20): [
client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)),
],
},
ungroupedClients: [
client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)),
],
query: 'brav',
);
expect(groups, hasLength(1));
expect(groups.single.channelName, 'Games');
expect(groups.single.clients.single.name, 'Bravo');
});
test('formats spacer channel names for filtered chat picker groups', () {
final groups = filterChatClientPickerGroups(
channels: [channel(BigInt.from(10), '[cSpacer]Lobby')],
clientsByChannel: {
BigInt.from(10): [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)),
],
},
ungroupedClients: const [],
query: '',
);
expect(groups.single.channelName, 'Lobby');
});
test('formats chat target titles', () {
expect(
chatTargetTitle(
const rust.BridgeMessageTarget.server(),
channelName: '',
clientName: '',
),
'Server Activity',
);
expect(
chatTargetTitle(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
clientName: '',
),
'# Lobby',
);
expect(
chatTargetTitle(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Alpha',
);
expect(
chatTargetTitle(
rust.BridgeMessageTarget.poke(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Poke: Alpha',
);
});
test('formats chat empty-state text', () {
expect(
chatEmptyTitle(const rust.BridgeMessageTarget.server()),
'No server activity yet',
);
expect(
chatEmptyBody(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
),
'Start the conversation in #Lobby.',
);
expect(
chatEmptyBody(const rust.BridgeMessageTarget.channel(), channelName: ''),
'Join a channel to start chatting.',
);
expect(
chatEmptyBody(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
),
'Select a user to start a private chat.',
);
});
test('formats chat input placeholders', () {
expect(
chatInputPlaceholder(
const rust.BridgeMessageTarget.channel(),
channelName: 'Lobby',
clientName: '',
),
'Message #Lobby...',
);
expect(
chatInputPlaceholder(
rust.BridgeMessageTarget.client(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Message Alpha...',
);
expect(
chatInputPlaceholder(
rust.BridgeMessageTarget.poke(BigInt.from(2)),
channelName: '',
clientName: 'Alpha',
),
'Poke message...',
);
});
testWidgets('chat hub does not expose pokes as a chat tab', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
entry(const rust.BridgeMessageTarget.server()),
],
snapshot: snapshot(channels: const [], clients: const []),
),
),
);
expect(find.text('Direct Messages'), findsOneWidget);
expect(find.text('Server Activity'), findsOneWidget);
expect(find.text('Pokes'), findsNothing);
expect(find.text('No pokes'), findsNothing);
});
testWidgets('channel chat displays localized poke history in English', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'EdisonJwa',
message: '111',
target: rust.BridgeMessageTarget.poke(BigInt.from(2)),
timestamp: DateTime(2026, 5, 23, 5, 11, 17),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.channel(),
),
),
);
expect(find.text('<05:11:17> "EdisonJwa" pokes you: 111'), findsOneWidget);
});
testWidgets('channel chat displays localized poke history in Chinese', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'EdisonJwa',
message: '',
target: rust.BridgeMessageTarget.poke(BigInt.from(2)),
timestamp: DateTime(2026, 5, 23, 5, 10, 37),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.channel(),
),
),
);
expect(find.text('<05:10:37> “EdisonJwa”戳了你一下'), findsOneWidget);
});
test('blocks channel chat when no voice channel is joined', () {
expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
isFalse,
);
expect(
chatSendBlockedReason(const rust.BridgeMessageTarget.channel(), null),
'Join a channel to send messages.',
);
expect(
canSendToChatTarget(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
),
isTrue,
);
expect(
chatSendBlockedReason(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
),
isNull,
);
expect(
canSendToChatTarget(
rust.BridgeMessageTarget.client(BigInt.from(2)),
null,
),
isTrue,
);
});
testWidgets('chat message TeamSpeak links use internal handler', (
tester,
) async {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.one,
senderName: 'Sender',
message:
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
target: rust.BridgeMessageTarget.server(),
),
],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.server(),
onTs3ServerLink: (link) async => tapped = link,
),
),
);
await tester.tap(
find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'),
);
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.host, 'teamspeak.app');
expect(tapped!.addBookmark, 'Vigorous Pro');
});
}