Handle pokes outside chat tabs
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 => '权限被拒绝';
|
||||
}
|
||||
|
||||
+724
-1342
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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user