Compare commits

...
Author SHA1 Message Date
Edison Jwa b841d3f3e4 docs(security): refresh license inventories 2026-06-09 07:30:23 +09:00
Edison Jwa eca77ece81 fix(chat): allow empty poke messages 2026-06-09 01:47:23 +09:00
Edison Jwa 3462de1eee fix(core): allow empty poke dispatch 2026-06-09 01:44:03 +09:00
Edison Jwa f66118f5bb docs: add poke-without-message design 2026-06-09 00:53:14 +09:00
Edison Jwa 3ef540ae37 Merge pull request #35 from EdisonJwa/feat/poke-notifications
feat: add poke notifications
2026-06-08 23:52:13 +09:00
Edison Jwa e0edcc89ac Merge pull request #34 from EdisonJwa/simplify-project-review
Maintainability continuation and Android smoke evidence
2026-06-08 23:46:37 +09:00
Edison Jwa 7922eabcf0 build(android): keep notification icon resource 2026-06-08 23:38:55 +09:00
Edison Jwa 82bfa0ea1f docs: remove trailing whitespace from continuation design 2026-06-08 23:36:05 +09:00
Edison Jwa b565663645 feat(chat): route pokes through notifications 2026-06-08 22:56:52 +09:00
Edison Jwa 34a5247457 feat(ui): add poke notification settings dialog 2026-06-08 22:56:04 +09:00
Edison Jwa 9a5f82565d feat(l10n): add poke notification settings copy 2026-06-08 22:55:38 +09:00
Edison Jwa 409cd11c21 feat(flutter): persist poke notification preferences 2026-06-08 22:55:12 +09:00
Edison Jwa 4f1b85cf76 feat(flutter): add poke notification service 2026-06-08 22:54:46 +09:00
Edison Jwa b7cc4d2336 build(apple): declare notification permission usage 2026-06-08 22:54:18 +09:00
Edison Jwa 44f91a2ea4 build(android): configure local notifications 2026-06-08 22:53:53 +09:00
Edison Jwa cc6db18199 build(flutter): add local notification plugin 2026-06-08 22:53:29 +09:00
Edison Jwa 6af2ed9ab5 feat(flutter): regenerate poke strength bridge 2026-06-08 22:53:01 +09:00
Edison Jwa cf64274fe6 feat(bridge): expose poke strength to Flutter 2026-06-08 22:52:33 +09:00
Edison Jwa 5c7a4b64c9 feat(core): propagate poke strength events 2026-06-08 22:52:08 +09:00
Edison Jwa 31cf45ce35 feat(protocol): classify poke notification strength 2026-06-08 22:51:41 +09:00
40 changed files with 2125 additions and 221 deletions
@@ -59,6 +59,7 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
@@ -198,6 +199,7 @@ android {
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
dependencies {
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3C8.69,3 6,5.69 6,9V13C6,16.31 8.69,19 12,19C15.31,19 18,16.31 18,13V9C18,5.69 15.31,3 12,3ZM12,5C14.21,5 16,6.79 16,9V13C16,15.21 14.21,17 12,17C9.79,17 8,15.21 8,13V9C8,6.79 9.79,5 12,5ZM11,20V22H13V20H11Z" />
</vector>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_chanora_notification" />
@@ -34,6 +34,8 @@
<string>Chanora needs local network access to connect to your voice servers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Chanora needs microphone access so you can talk on your voice server.</string>
<key>NSUserNotificationsUsageDescription</key>
<string>Chanora sends you a notification when another user pokes you.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+19 -8
View File
@@ -242,19 +242,30 @@
"clientInfoUnknown": "Unknown",
"clientInfoHidden": "Hidden",
"clientInfoNone": "None",
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
"@pokeSnackBarIncomingNoMessage": {
"pokeSettingsAction": "Poke notifications",
"pokeSettingsTitle": "Poke notifications",
"pokeSettingsEnableLabel": "Notify me about pokes",
"pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.",
"pokeSettingsMutedSendersHeader": "Muted senders",
"pokeSettingsMutedSendersEmpty": "No muted poke senders.",
"pokeSettingsMutedSenderLabel": "Client ID {senderId}",
"@pokeSettingsMutedSenderLabel": {
"placeholders": {
"senderId": { "type": "String" }
}
},
"pokeSettingsUnmuteSenderAction": "Unmute",
"pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?",
"@pokeOverflowMutePrompt": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
"@pokeSnackBarIncomingWithMessage": {
"pokeOverflowMuteAction": "Mute",
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" },
"message": { "type": "String" }
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
+19 -8
View File
@@ -191,19 +191,30 @@
"clientInfoUnknown": "未知",
"clientInfoHidden": "隐藏",
"clientInfoNone": "无",
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
"@pokeSnackBarIncomingNoMessage": {
"pokeSettingsAction": "戳一戳通知",
"pokeSettingsTitle": "戳一戳通知",
"pokeSettingsEnableLabel": "接收戳一戳通知",
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
"pokeSettingsMutedSendersHeader": "已静音的发送者",
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
"@pokeSettingsMutedSenderLabel": {
"placeholders": {
"senderId": { "type": "String" }
}
},
"pokeSettingsUnmuteSenderAction": "取消静音",
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
"@pokeOverflowMutePrompt": {
"placeholders": {
"sender": { "type": "String" }
}
},
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
"@pokeSnackBarIncomingWithMessage": {
"pokeOverflowMuteAction": "静音",
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
"@pokeMutedSenderConfirmation": {
"placeholders": {
"sender": { "type": "String" },
"message": { "type": "String" }
"sender": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
@@ -1159,29 +1159,71 @@ abstract class AppL10n {
/// **'None'**
String get clientInfoNone;
/// No description provided for @pokeSnackBarClearAction.
/// No description provided for @pokeSettingsAction.
///
/// In en, this message translates to:
/// **'Clear'**
String get pokeSnackBarClearAction;
/// **'Poke notifications'**
String get pokeSettingsAction;
/// No description provided for @pokeSnackBarMoreIndicator.
/// No description provided for @pokeSettingsTitle.
///
/// In en, this message translates to:
/// **'...'**
String get pokeSnackBarMoreIndicator;
/// **'Poke notifications'**
String get pokeSettingsTitle;
/// No description provided for @pokeSnackBarIncomingNoMessage.
/// No description provided for @pokeSettingsEnableLabel.
///
/// In en, this message translates to:
/// **'{sender} pokes you'**
String pokeSnackBarIncomingNoMessage(String sender);
/// **'Notify me about pokes'**
String get pokeSettingsEnableLabel;
/// No description provided for @pokeSnackBarIncomingWithMessage.
/// No description provided for @pokeSettingsEnableDescription.
///
/// In en, this message translates to:
/// **'{sender} pokes you: {message}'**
String pokeSnackBarIncomingWithMessage(String sender, String message);
/// **'Show local notifications for incoming pokes when this is on.'**
String get pokeSettingsEnableDescription;
/// No description provided for @pokeSettingsMutedSendersHeader.
///
/// In en, this message translates to:
/// **'Muted senders'**
String get pokeSettingsMutedSendersHeader;
/// No description provided for @pokeSettingsMutedSendersEmpty.
///
/// In en, this message translates to:
/// **'No muted poke senders.'**
String get pokeSettingsMutedSendersEmpty;
/// No description provided for @pokeSettingsMutedSenderLabel.
///
/// In en, this message translates to:
/// **'Client ID {senderId}'**
String pokeSettingsMutedSenderLabel(String senderId);
/// No description provided for @pokeSettingsUnmuteSenderAction.
///
/// In en, this message translates to:
/// **'Unmute'**
String get pokeSettingsUnmuteSenderAction;
/// No description provided for @pokeOverflowMutePrompt.
///
/// In en, this message translates to:
/// **'Repeated pokes from {sender} were suppressed. Mute this sender?'**
String pokeOverflowMutePrompt(String sender);
/// No description provided for @pokeOverflowMuteAction.
///
/// In en, this message translates to:
/// **'Mute'**
String get pokeOverflowMuteAction;
/// No description provided for @pokeMutedSenderConfirmation.
///
/// In en, this message translates to:
/// **'Muted pokes from {sender}'**
String pokeMutedSenderConfirmation(String sender);
/// No description provided for @pokeHistorySelfNoMessage.
///
@@ -590,19 +590,43 @@ class AppL10nEn extends AppL10n {
String get clientInfoNone => 'None';
@override
String get pokeSnackBarClearAction => 'Clear';
String get pokeSettingsAction => 'Poke notifications';
@override
String get pokeSnackBarMoreIndicator => '...';
String get pokeSettingsTitle => 'Poke notifications';
@override
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender pokes you';
String get pokeSettingsEnableLabel => 'Notify me about pokes';
@override
String get pokeSettingsEnableDescription =>
'Show local notifications for incoming pokes when this is on.';
@override
String get pokeSettingsMutedSendersHeader => 'Muted senders';
@override
String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.';
@override
String pokeSettingsMutedSenderLabel(String senderId) {
return 'Client ID $senderId';
}
@override
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender pokes you: $message';
String get pokeSettingsUnmuteSenderAction => 'Unmute';
@override
String pokeOverflowMutePrompt(String sender) {
return 'Repeated pokes from $sender were suppressed. Mute this sender?';
}
@override
String get pokeOverflowMuteAction => 'Mute';
@override
String pokeMutedSenderConfirmation(String sender) {
return 'Muted pokes from $sender';
}
@override
@@ -577,19 +577,42 @@ class AppL10nZh extends AppL10n {
String get clientInfoNone => '';
@override
String get pokeSnackBarClearAction => '清除';
String get pokeSettingsAction => '戳一戳通知';
@override
String get pokeSnackBarMoreIndicator => '...';
String get pokeSettingsTitle => '戳一戳通知';
@override
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender 戳了你一下';
String get pokeSettingsEnableLabel => '接收戳一戳通知';
@override
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
@override
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
@override
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
@override
String pokeSettingsMutedSenderLabel(String senderId) {
return '用户 ID $senderId';
}
@override
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender 戳了你一下:$message';
String get pokeSettingsUnmuteSenderAction => '取消静音';
@override
String pokeOverflowMutePrompt(String sender) {
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
}
@override
String get pokeOverflowMuteAction => '静音';
@override
String pokeMutedSenderConfirmation(String sender) {
return '已静音来自 $sender 的戳一戳';
}
@override
+100 -145
View File
@@ -28,6 +28,8 @@ import 'services/connection_phase_state.dart';
import 'services/hard_mute_owners.dart';
import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/poke_notification_service.dart';
import 'services/poke_preferences_service.dart';
import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
@@ -43,6 +45,7 @@ import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/poke_notification_settings.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -161,10 +164,7 @@ class _ChanoraAppState extends State<ChanoraApp> {
supportedLocales: AppL10n.supportedLocales,
home: Stack(
children: [
_BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
_BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode),
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
],
),
@@ -202,6 +202,19 @@ extension on ThemeMode {
}
}
bool isPokeSenderActiveChat({
required bool chatOpen,
required rust.BridgeMessageTarget? inlineChatTarget,
required BigInt senderId,
}) {
if (!chatOpen) return false;
return switch (inlineChatTarget) {
rust.BridgeMessageTarget_Poke(:final field0) ||
rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId,
_ => false,
};
}
class ChanoraThemeModeMenu extends StatelessWidget {
const ChanoraThemeModeMenu({
super.key,
@@ -302,18 +315,6 @@ class _BetaHome extends StatefulWidget {
State<_BetaHome> createState() => _BetaHomeState();
}
class _ReceivedPoke {
const _ReceivedPoke({
required this.senderName,
required this.message,
required this.receivedAt,
});
final String senderName;
final String message;
final DateTime receivedAt;
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
@@ -412,11 +413,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeSnackBarVisible = false;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
@@ -431,6 +427,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// MethodChannel.
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
final PokeNotificationService _pokeNotifications = PokeNotificationService();
final PokePreferencesService _pokePreferences = PokePreferencesService();
late final Future<void> _pokePreferencesReady;
@override
void initState() {
@@ -464,6 +463,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
unawaited(_pokeNotifications.init());
_pokePreferencesReady = _pokePreferences.load();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
@@ -867,10 +868,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
:final senderName,
:final message,
:final target,
:final pokeStrength,
):
// Skip echo of self-sent messages (already added locally).
if (senderId == _snapshot?.ownClientId) return;
final isPoke = target is rust.BridgeMessageTarget_Poke;
// Skip echo of self-sent non-poke messages (already added locally).
if (!isPoke && senderId == _snapshot?.ownClientId) return;
final receivedAt = DateTime.now();
setState(() {
_appendChatEntryUnlocked(
@@ -885,10 +887,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
});
if (isPoke) {
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
unawaited(
_handleIncomingPoke(
senderId: senderId,
senderName: senderName,
message: message,
strength: pokeStrength,
),
);
return;
}
@@ -1088,7 +1093,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_nickCtl.dispose();
_passwordCtl.dispose();
_chatFeedRevision.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -1105,6 +1109,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_androidPermissions.stop();
_iosPermissions.stop();
_macOSPermissions.stop();
_pokePreferences.dispose();
super.dispose();
}
@@ -1138,7 +1143,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
if (accessState == MacOSLocalNetworkState.denied) {
if (!mounted) return;
setState(() { _phase = ConnectionPhase.idle; });
setState(() {
_phase = ConnectionPhase.idle;
});
_showLocalNetworkDeniedSnackBar();
return;
}
@@ -1520,6 +1527,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
Future<void> _onOpenPokeSettings() async {
await _pokePreferences.load();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) =>
PokeNotificationSettingsDialog(preferences: _pokePreferences),
);
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
@@ -1804,9 +1821,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
),
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
);
});
}
@@ -1827,52 +1842,68 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
}
void _showPokeSnackBar({
Future<void> _handleIncomingPoke({
required BigInt senderId,
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
required rust.BridgePokeStrength? strength,
}) async {
if (senderId == _snapshot?.ownClientId) return;
await _pokePreferencesReady;
if (!_pokePreferences.pokesEnabled.value) return;
if (_pokePreferences.isMuted(senderId)) return;
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
}
if (_isPokeSenderActiveChat(senderId)) return;
await _pokeNotifications.show(
senderName: senderName,
message: message,
senderId: senderId,
strength: pokeStrength,
);
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
bool _isPokeSenderActiveChat(BigInt senderId) {
return isPokeSenderActiveChat(
chatOpen: _chatOpen,
inlineChatTarget: _inlineChatTarget,
senderId: senderId,
);
}
void _showPokeOverflowMutePrompt({
required BigInt senderId,
required String senderName,
}) {
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
duration: const Duration(seconds: 8),
content: Text(
l10n.pokeOverflowMutePrompt(senderName),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
label: l10n.pokeOverflowMuteAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
unawaited(_pokePreferences.muteSender(senderId));
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
),
);
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1880,7 +1911,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -2377,6 +2407,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
themeMode: widget.themeMode,
onThemeModeChanged: widget.onThemeModeChanged,
),
IconButton(
tooltip: l10n.pokeSettingsAction,
icon: const Icon(Icons.notifications_outlined),
onPressed: () => unawaited(_onOpenPokeSettings()),
),
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
@@ -2856,83 +2891,3 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
);
}
}
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
);
}
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
@@ -0,0 +1,179 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../src/rust/api.dart' as rust;
class PokeNotificationService {
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
static const _strongAndroidChannelId = 'chanora_pokes_strong_v1';
static const _defaultAndroidChannelId = 'chanora_pokes_default_v1';
static const _groupKey = 'chanora.pokes';
static const _darwinThreadId = 'chanora.pokes';
static const _windowsHeader = WindowsHeader(
id: 'chanora.pokes',
title: 'Pokes',
arguments: 'pokes',
);
static const _windowsAppUserModelId = 'Chanora.Client';
static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E';
final FlutterLocalNotificationsPlugin _notifications;
bool _initialized = false;
Future<void> init() async {
if (_initialized) return;
await _notifications.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_chanora_notification'),
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
requestSoundPermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultPresentSound: false,
),
macOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
requestSoundPermission: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultPresentSound: false,
),
linux: LinuxInitializationSettings(
defaultActionName: 'Open',
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
defaultSuppressSound: true,
),
windows: WindowsInitializationSettings(
appName: 'Chanora',
appUserModelId: _windowsAppUserModelId,
guid: _windowsGuid,
),
),
);
_initialized = true;
}
Future<bool> requestPermission() async {
await init();
if (kIsWeb) return true;
final android = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (android != null) {
return await android.requestNotificationsPermission() ?? true;
}
final ios = _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (ios != null) {
return await ios.requestPermissions(alert: true, badge: true) ?? false;
}
final macOS = _notifications
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>();
if (macOS != null) {
return await macOS.requestPermissions(alert: true, badge: true) ?? false;
}
return true;
}
Future<void> show({
required String senderName,
required String message,
required BigInt senderId,
required rust.BridgePokeStrength strength,
}) async {
await init();
final permitted = await requestPermission();
if (!permitted) return;
final trimmedMessage = message.trim();
final body = trimmedMessage.isEmpty
? '$senderName pokes you'
: trimmedMessage;
await _notifications.show(
id: senderId.toUnsigned(31).toInt(),
title: 'Poke from $senderName',
body: body,
notificationDetails: NotificationDetails(
android: _androidDetails(strength),
iOS: _darwinDetails(strength),
macOS: _darwinDetails(strength),
linux: _linuxDetails(strength),
windows: _windowsDetails(strength),
),
payload: 'poke:$senderId',
);
}
AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) {
final isStrong = strength == rust.BridgePokeStrength.strong;
return AndroidNotificationDetails(
isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId,
isStrong ? 'Pokes' : 'Pokes (quiet)',
channelDescription: 'TeamSpeak poke notifications',
importance: isStrong ? Importance.max : Importance.defaultImportance,
priority: isStrong ? Priority.high : Priority.defaultPriority,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
playSound: false,
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
silent: true,
groupKey: _groupKey,
category: AndroidNotificationCategory.message,
visibility: NotificationVisibility.private,
);
}
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
return DarwinNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
presentSound: false,
threadIdentifier: _darwinThreadId,
interruptionLevel: switch (strength) {
rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive,
rust.BridgePokeStrength.suppressed => InterruptionLevel.active,
rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive,
},
);
}
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
return LinuxNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
suppressSound: true,
urgency: switch (strength) {
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal,
rust.BridgePokeStrength.suppressedOverflow =>
LinuxNotificationUrgency.low,
},
);
}
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
return WindowsNotificationDetails(
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
audio: WindowsNotificationAudio.silent(),
header: _windowsHeader,
scenario: strength == rust.BridgePokeStrength.strong
? WindowsNotificationScenario.urgent
: null,
duration: strength == rust.BridgePokeStrength.strong
? WindowsNotificationDuration.long
: WindowsNotificationDuration.short,
);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PokePreferencesService {
static const _enabledKey = 'pokes.enabled';
static const _mutedSendersKey = 'pokes.muted_senders';
final ValueNotifier<bool> _pokesEnabled = ValueNotifier<bool>(true);
final ValueNotifier<Set<BigInt>> _mutedSenders = ValueNotifier<Set<BigInt>>(
const <BigInt>{},
);
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
_mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
.map(BigInt.parse)
.toSet();
}
Future<void> setPokesEnabled(bool enabled) async {
_pokesEnabled.value = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
}
Future<void> muteSender(BigInt senderId) async {
if (_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = {..._mutedSenders.value, senderId};
await _saveMutedSenders();
}
Future<void> unmuteSender(BigInt senderId) async {
if (!_mutedSenders.value.contains(senderId)) return;
_mutedSenders.value = _mutedSenders.value
.where((mutedSender) => mutedSender != senderId)
.toSet();
await _saveMutedSenders();
}
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
Future<void> _saveMutedSenders() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_mutedSendersKey,
_mutedSenders.value.map((senderId) => senderId.toString()).toList(),
);
}
void dispose() {
_pokesEnabled.dispose();
_mutedSenders.dispose();
}
}
+89 -5
View File
@@ -11,7 +11,7 @@ part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -263,7 +263,8 @@ Future<BridgeAudioStats> audioStats() =>
/// Subscribe to real-time microphone input level at ~30 Hz.
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
/// when the Dart subscriber cancels or the session is dropped.
/// when the Dart subscriber cancels, the session is dropped, or
/// the session becomes persistently unavailable.
Stream<double> inputLevelStream() =>
RustLib.instance.api.crateApiInputLevelStream();
@@ -1209,6 +1210,9 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Target scope (server/channel/private/poke).
required BridgeMessageTarget target,
/// Poke notification strength, present only for poke messages.
BridgePokeStrength? pokeStrength,
}) = BridgeEvent_ChatMessage;
/// Human-readable server activity surfaced from protocol bookkeeping events.
@@ -1219,48 +1223,116 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Audio route changed (speaker/earpiece/BT/wired).
const factory BridgeEvent.audioRouteChanged({
/// New audio output route.
required BridgeAudioRoute route,
}) = BridgeEvent_AudioRouteChanged;
/// A client moved to a different channel.
const factory BridgeEvent.clientMoved({
/// Unique client identifier.
required BigInt clientId,
/// Destination channel.
required BigInt newChannelId,
}) = BridgeEvent_ClientMoved;
/// A new client connected.
const factory BridgeEvent.clientJoined({
/// Unique client identifier.
required BigInt clientId,
/// Channel the client joined.
required BigInt channelId,
/// Display nickname.
required String name,
/// Microphone muted state.
required bool inputMuted,
/// Speaker muted state.
required bool outputMuted,
/// True for server query (bot) clients.
required bool isServerQuery,
/// Client's talk power value.
required int talkPower,
/// Whether the server granted temporary talk power.
required bool talkPowerGranted,
}) = BridgeEvent_ClientJoined;
/// A client disconnected.
const factory BridgeEvent.clientLeft({
/// Unique client identifier.
required BigInt clientId,
/// Display nickname at time of disconnect.
required String name,
}) = BridgeEvent_ClientLeft;
/// Client properties changed.
const factory BridgeEvent.clientUpdated({
/// Unique client identifier.
required BigInt clientId,
/// Microphone muted state.
required bool inputMuted,
/// Speaker muted state.
required bool outputMuted,
/// True for server query (bot) clients.
required bool isServerQuery,
/// Client's talk power value.
required int talkPower,
/// Whether the server granted temporary talk power.
required bool talkPowerGranted,
}) = BridgeEvent_ClientUpdated;
/// A new channel appeared.
const factory BridgeEvent.channelAdded({
/// Unique channel identifier.
required BigInt id,
/// Parent channel ID.
required BigInt parent,
/// Channel name.
required String name,
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
required PlatformInt64 order,
/// Whether the channel requires a password.
required bool hasPassword,
/// Talk power required to speak; `None` means no restriction.
int? neededTalkPower,
}) = BridgeEvent_ChannelAdded;
const factory BridgeEvent.channelRemoved({required BigInt id}) =
BridgeEvent_ChannelRemoved;
const factory BridgeEvent.channelUpdated({
/// A channel was deleted.
const factory BridgeEvent.channelRemoved({
/// Channel identifier.
required BigInt id,
}) = BridgeEvent_ChannelRemoved;
/// Channel properties changed.
const factory BridgeEvent.channelUpdated({
/// Unique channel identifier.
required BigInt id,
/// Channel name.
required String name,
/// Whether the channel requires a password.
required bool hasPassword,
/// Talk power required to speak; `None` means no restriction.
int? neededTalkPower,
}) = BridgeEvent_ChannelUpdated;
}
@@ -1306,6 +1378,18 @@ enum BridgeNetworkState {
offline,
}
/// Bridge poke notification strength.
enum BridgePokeStrength {
/// Poke should be surfaced at full strength.
strong,
/// Poke is rate-limited but below overflow severity.
suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
suppressedOverflow,
}
/// Persisted PTT binding display state for the UI.
class BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
@@ -173,7 +173,7 @@ return channelUpdated(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -186,7 +186,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
@@ -213,7 +213,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -226,7 +226,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity():
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
@@ -249,7 +249,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -262,7 +262,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
@@ -929,7 +929,7 @@ as PermissionStateKind,
class BridgeEvent_ChatMessage extends BridgeEvent {
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._();
/// Client id of the sender.
@@ -940,6 +940,8 @@ class BridgeEvent_ChatMessage extends BridgeEvent {
final String message;
/// Target scope (server/channel/private/poke).
final BridgeMessageTarget target;
/// Poke notification strength, present only for poke messages.
final BridgePokeStrength? pokeStrength;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@@ -951,16 +953,16 @@ $BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$Brid
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
}
@override
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
@override
String toString() {
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
}
@@ -971,7 +973,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
@useResult
$Res call({
BigInt senderId, String senderName, String message, BridgeMessageTarget target
BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength
});
@@ -988,13 +990,14 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,Object? pokeStrength = freezed,}) {
return _then(BridgeEvent_ChatMessage(
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
as BridgeMessageTarget,
as BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
as BridgePokeStrength?,
));
}
@@ -1084,6 +1087,7 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// New audio output route.
final BridgeAudioRoute route;
/// Create a copy of BridgeEvent
@@ -1150,7 +1154,9 @@ class BridgeEvent_ClientMoved extends BridgeEvent {
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Destination channel.
final BigInt newChannelId;
/// Create a copy of BridgeEvent
@@ -1218,13 +1224,21 @@ class BridgeEvent_ClientJoined extends BridgeEvent {
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Channel the client joined.
final BigInt channelId;
/// Display nickname.
final String name;
/// Microphone muted state.
final bool inputMuted;
/// Speaker muted state.
final bool outputMuted;
/// True for server query (bot) clients.
final bool isServerQuery;
/// Client's talk power value.
final int talkPower;
/// Whether the server granted temporary talk power.
final bool talkPowerGranted;
/// Create a copy of BridgeEvent
@@ -1298,7 +1312,9 @@ class BridgeEvent_ClientLeft extends BridgeEvent {
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Display nickname at time of disconnect.
final String name;
/// Create a copy of BridgeEvent
@@ -1366,11 +1382,17 @@ class BridgeEvent_ClientUpdated extends BridgeEvent {
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
/// Unique client identifier.
final BigInt clientId;
/// Microphone muted state.
final bool inputMuted;
/// Speaker muted state.
final bool outputMuted;
/// True for server query (bot) clients.
final bool isServerQuery;
/// Client's talk power value.
final int talkPower;
/// Whether the server granted temporary talk power.
final bool talkPowerGranted;
/// Create a copy of BridgeEvent
@@ -1442,11 +1464,18 @@ class BridgeEvent_ChannelAdded extends BridgeEvent {
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
/// Unique channel identifier.
final BigInt id;
/// Parent channel ID.
final BigInt parent;
/// Channel name.
final String name;
/// Predecessor channel ID within the same parent (TeamSpeak
/// linked-list ordering hint). Zero means first child.
final PlatformInt64 order;
/// Whether the channel requires a password.
final bool hasPassword;
/// Talk power required to speak; `None` means no restriction.
final int? neededTalkPower;
/// Create a copy of BridgeEvent
@@ -1518,6 +1547,7 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
const BridgeEvent_ChannelRemoved({required this.id}): super._();
/// Channel identifier.
final BigInt id;
/// Create a copy of BridgeEvent
@@ -1584,9 +1614,13 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent {
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
/// Unique channel identifier.
final BigInt id;
/// Channel name.
final String name;
/// Whether the channel requires a password.
final bool hasPassword;
/// Talk power required to speak; `None` means no restriction.
final int? neededTalkPower;
/// Create a copy of BridgeEvent
@@ -1683,6 +1683,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_bridge_message_target(raw);
}
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_bridge_poke_strength(raw);
}
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -2007,6 +2013,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
senderName: dco_decode_String(raw[2]),
message: dco_decode_String(raw[3]),
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]),
);
case 11:
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
@@ -2098,6 +2105,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeNetworkState.values[raw as int];
}
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return BridgePokeStrength.values[raw as int];
}
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2234,6 +2247,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw == null ? null : dco_decode_String(raw);
}
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null
? null
: dco_decode_box_autoadd_bridge_poke_strength(raw);
}
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
@@ -2358,6 +2381,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_bridge_message_target(deserializer));
}
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_bridge_poke_strength(deserializer));
}
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -2809,11 +2840,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_target = sse_decode_box_autoadd_bridge_message_target(
deserializer,
);
var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength(
deserializer,
);
return BridgeEvent_ChatMessage(
senderId: var_senderId,
senderName: var_senderName,
message: var_message,
target: var_target,
pokeStrength: var_pokeStrength,
);
case 11:
var var_message = sse_decode_String(deserializer);
@@ -2941,6 +2976,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return BridgeNetworkState.values[inner];
}
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var inner = sse_decode_i_32(deserializer);
return BridgePokeStrength.values[inner];
}
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3132,6 +3176,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_bridge_poke_strength(deserializer));
} else {
return null;
}
}
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -3306,6 +3363,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bridge_message_target(self, serializer);
}
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bridge_poke_strength(self, serializer);
}
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -3644,12 +3710,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
senderName: final senderName,
message: final message,
target: final target,
pokeStrength: final pokeStrength,
):
sse_encode_i_32(10, serializer);
sse_encode_u_64(senderId, serializer);
sse_encode_String(senderName, serializer);
sse_encode_String(message, serializer);
sse_encode_box_autoadd_bridge_message_target(target, serializer);
sse_encode_opt_box_autoadd_bridge_poke_strength(
pokeStrength,
serializer,
);
case BridgeEvent_ServerActivity(message: final message):
sse_encode_i_32(11, serializer);
sse_encode_String(message, serializer);
@@ -3771,6 +3842,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -3947,6 +4027,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_bridge_poke_strength(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -120,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@@ -174,6 +180,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@@ -240,6 +251,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -328,6 +344,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@@ -400,6 +421,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -477,6 +503,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -588,6 +620,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -681,6 +719,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
@@ -122,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@@ -176,6 +182,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
dynamic raw,
);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@@ -242,6 +253,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
@@ -330,6 +346,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgePokeStrength sse_decode_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@@ -402,6 +423,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
@@ -479,6 +505,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
@@ -590,6 +622,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_poke_strength(
BridgePokeStrength self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
@@ -683,6 +721,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_poke_strength(
BridgePokeStrength? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
typedef ChatMessageSender =
Future<void> Function({
required String message,
required rust.BridgeMessageTarget target,
});
/// One chat/activity message shown in the chat hub.
class ChatEntry {
/// Construct a chat entry.
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
case rust.BridgeMessageTarget_Client():
return 'Message $clientName...';
case rust.BridgeMessageTarget_Poke():
return 'Poke message...';
return 'Poke message optional...';
}
}
@@ -513,6 +519,16 @@ bool canSendToChatTarget(
}
}
bool canSendChatMessage(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
String text,
) {
if (!canSendToChatTarget(target, currentChannelId)) return false;
if (target is rust.BridgeMessageTarget_Poke) return true;
return text.trim().isNotEmpty;
}
String? chatSendBlockedReason(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
this.sendChatMessage,
});
/// Chat target displayed by this detail view.
@@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget {
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
/// Sends a chat message. Defaults to the Rust bridge send path.
final ChatMessageSender? sendChatMessage;
@override
State<ChatDetailView> createState() => _ChatDetailViewState();
}
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
void _send() {
final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend) return;
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
return;
}
_textCtl.clear();
unawaited(rust.sendChatMessage(message: text, target: widget.target));
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
unawaited(sendChatMessage(message: text, target: widget.target));
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
channelName: widget.channelName,
clientName: widget.clientName,
);
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
? 'Poke'
: 'Send';
return Column(
children: [
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
tooltip: sendTooltip,
),
],
),
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/poke_preferences_service.dart';
import 'voice_settings_controls.dart';
class PokeNotificationSettingsDialog extends StatelessWidget {
const PokeNotificationSettingsDialog({super.key, required this.preferences});
final PokePreferencesService preferences;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pokeSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<bool>(
valueListenable: preferences.pokesEnabled,
builder: (context, enabled, _) => SwitchListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(l10n.pokeSettingsEnableLabel),
subtitle: Text(l10n.pokeSettingsEnableDescription),
value: enabled,
onChanged: (value) => preferences.setPokesEnabled(value),
),
),
const Divider(height: 24),
VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader),
ValueListenableBuilder<Set<BigInt>>(
valueListenable: preferences.mutedSenders,
builder: (context, mutedSenders, _) {
final senders = mutedSenders.toList()..sort();
if (senders.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: Text(
l10n.pokeSettingsMutedSendersEmpty,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final senderId in senders)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.notifications_off_outlined),
title: Text(
l10n.pokeSettingsMutedSenderLabel(
senderId.toString(),
),
),
trailing: TextButton(
onPressed: () => preferences.unmuteSender(senderId),
child: Text(l10n.pokeSettingsUnmuteSenderAction),
),
),
],
);
},
),
const SizedBox(height: 8),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
],
);
}
}
@@ -42,6 +42,8 @@
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
<key>NSUserNotificationsUsageDescription</key>
<string>Chanora sends you a notification when another user pokes you.</string>
<key>NSBonjourServices</key>
<array>
<string>_ts3._tcp</string>
+48
View File
@@ -262,6 +262,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
url: "https://pub.dev"
source: hosted
version: "22.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev"
source: hosted
version: "8.0.1"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev"
source: hosted
version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -794,6 +834,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
timezone:
dependency: transitive
description:
name: timezone
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.11.0"
typed_data:
dependency: transitive
description:
+1
View File
@@ -75,6 +75,7 @@ dependencies:
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
haptic_kit: ^1.0.0
flutter_foreground_task: ^9.2.2
flutter_local_notifications: ^22.0.0
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
share_plus: ^13.1.0
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/main.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
test('active poke chat suppresses same sender notification only', () {
final sender = BigInt.from(42);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.poke(sender),
senderId: sender,
),
isTrue,
);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.poke(BigInt.from(7)),
senderId: sender,
),
isFalse,
);
});
test('active private chat also suppresses same sender poke notification', () {
final sender = BigInt.from(42);
expect(
isPokeSenderActiveChat(
chatOpen: true,
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
senderId: sender,
),
isTrue,
);
expect(
isPokeSenderActiveChat(
chatOpen: false,
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
senderId: sender,
),
isFalse,
);
});
}
@@ -0,0 +1,81 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chanora_flutter/services/poke_notification_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
late List<MethodCall> calls;
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return switch (call.method) {
'initialize' => true,
'requestNotificationsPermission' => true,
_ => null,
};
});
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test(
'show dispatches a silent poke notification with sender payload',
() async {
final service = PokeNotificationService();
await service.show(
senderName: 'Alice',
message: 'wake up',
senderId: BigInt.from(42),
strength: rust.BridgePokeStrength.strong,
);
final showCall = calls.singleWhere((call) => call.method == 'show');
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['id'], 42);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'wake up');
expect(arguments['payload'], 'poke:42');
final specifics = Map<Object?, Object?>.from(
arguments['platformSpecifics'] as Map,
);
expect(specifics['silent'], true);
expect(specifics['playSound'], false);
expect(specifics['groupKey'], 'chanora.pokes');
},
);
test('show uses fallback body for empty poke messages', () async {
final service = PokeNotificationService();
await service.show(
senderName: 'Alice',
message: ' ',
senderId: BigInt.from(42),
strength: rust.BridgePokeStrength.strong,
);
final showCall = calls.singleWhere((call) => call.method == 'show');
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'Alice pokes you');
expect(arguments['payload'], 'poke:42');
});
}
@@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/poke_preferences_service.dart';
void main() {
late PokePreferencesService service;
setUp(() {
SharedPreferences.setMockInitialValues({});
service = PokePreferencesService();
});
test('loads defaults when preferences are unset', () async {
await service.load();
expect(service.pokesEnabled.value, isTrue);
expect(service.mutedSenders.value, isEmpty);
});
test('persists global enabled state', () async {
await service.load();
await service.setPokesEnabled(false);
final reloaded = PokePreferencesService();
await reloaded.load();
expect(reloaded.pokesEnabled.value, isFalse);
});
test('persists muted senders and removes them on unmute', () async {
await service.load();
final alice = BigInt.from(42);
final bob = BigInt.from(7);
await service.muteSender(alice);
await service.muteSender(bob);
await service.unmuteSender(alice);
final reloaded = PokePreferencesService();
await reloaded.load();
expect(reloaded.mutedSenders.value, {bob});
});
test('isMuted reflects in-memory changes synchronously', () async {
await service.load();
final sender = BigInt.from(99);
expect(service.isMuted(sender), isFalse);
await service.muteSender(sender);
expect(service.isMuted(sender), isTrue);
await service.unmuteSender(sender);
expect(service.isMuted(sender), isFalse);
});
}
@@ -455,7 +455,7 @@ void main() {
channelName: '',
clientName: 'Alpha',
),
'Poke message...',
'Poke message optional...',
);
});
@@ -737,6 +737,179 @@ void main() {
refresh.dispose();
});
test('evaluates target-aware chat message send policy', () {
final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2));
final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2));
expect(canSendChatMessage(pokeTarget, null, ''), isTrue);
expect(canSendChatMessage(pokeTarget, null, ' '), isTrue);
expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue);
expect(
canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'',
),
isFalse,
);
expect(canSendChatMessage(clientTarget, null, ''), isFalse);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
null,
'hello',
),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'hello',
),
isTrue,
);
});
testWidgets('poke detail sends an empty poke when the composer is empty', (
tester,
) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
expect(find.byTooltip('Poke'), findsOneWidget);
expect(find.byTooltip('Send'), findsNothing);
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, '');
expect(sentTarget, target);
expect(messages, hasLength(1));
expect(messages.single.isPoke, isTrue);
expect(messages.single.message, '');
expect(find.textContaining('You poked "Alpha"'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
});
testWidgets('poke detail sends typed optional poke message', (tester) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
await tester.enterText(find.byType(TextField), 'wake up');
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, 'wake up');
expect(sentTarget, target);
expect(messages.single.message, 'wake up');
expect(
find.textContaining('You poked "Alpha" with message: wake up'),
findsOneWidget,
);
});
testWidgets('channel detail blocks empty sends with a joined channel', (
tester,
) async {
var sendCount = 0;
final messages = <ChatEntry>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: [channel(BigInt.from(10), 'Lobby')],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
],
),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
sendChatMessage: ({required message, required target}) async {
sendCount++;
},
),
),
),
);
expect(find.byTooltip('Send'), findsOneWidget);
await tester.tap(find.byTooltip('Send'));
await tester.pump();
expect(sendCount, 0);
expect(messages, isEmpty);
});
test('blocks channel chat when no voice channel is joined', () {
expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
@@ -0,0 +1,37 @@
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/poke_preferences_service.dart';
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
void main() {
testWidgets('toggles poke notifications and unmutes senders', (tester) async {
SharedPreferences.setMockInitialValues({});
final preferences = PokePreferencesService();
await preferences.load();
await preferences.muteSender(BigInt.from(42));
addTearDown(preferences.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: PokeNotificationSettingsDialog(preferences: preferences),
),
);
expect(find.text('Poke notifications'), findsOneWidget);
expect(find.text('Client ID 42'), findsOneWidget);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(preferences.pokesEnabled.value, isFalse);
await tester.tap(find.text('Unmute'));
await tester.pumpAndSettle();
expect(preferences.isMuted(BigInt.from(42)), isFalse);
expect(find.text('No muted poke senders.'), findsOneWidget);
});
}
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
jni
)
+3 -1
View File
@@ -1,5 +1,5 @@
use chanora_audio::{AudioRoute, PttBackendDescriptor};
use chanora_protocol::MessageTarget;
use chanora_protocol::{MessageTarget, PokeStrength};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -139,6 +139,8 @@ pub enum SessionEvent {
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
/// Poke notification strength, present only for poke messages.
poke_strength: Option<PokeStrength>,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
+39 -2
View File
@@ -68,7 +68,7 @@ pub use chanora_diagnostics::{
};
pub use chanora_protocol::{
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
pub use events::{
@@ -174,6 +174,10 @@ fn normalize_channel_password(password: Option<String>) -> Option<String> {
.filter(|p| !p.is_empty())
}
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
}
/// The top-level Chanora session. Owns at most one active server
/// connection (DEC-006).
#[derive(Clone)]
@@ -673,7 +677,7 @@ impl ChanoraSession {
message: String,
target: MessageTarget,
) -> Result<(), CoreError> {
if message.trim().is_empty() {
if !should_dispatch_text_message(&message, &target) {
return Ok(());
}
let guard = self.inner.lock().await;
@@ -1674,6 +1678,7 @@ fn spawn_event_forwarders(
sender_name: msg.sender_name,
message: msg.message,
target: msg.target,
poke_strength: msg.poke_strength,
});
}
});
@@ -2534,6 +2539,38 @@ mod tests {
);
}
#[test]
fn empty_poke_messages_are_dispatchable() {
assert!(super::should_dispatch_text_message(
"",
&MessageTarget::Poke(42)
));
assert!(super::should_dispatch_text_message(
" \t ",
&MessageTarget::Poke(42)
));
}
#[test]
fn empty_non_poke_messages_remain_suppressed() {
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Server
));
assert!(!super::should_dispatch_text_message(
" ",
&MessageTarget::Channel
));
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Client(42)
));
assert!(super::should_dispatch_text_message(
"hello",
&MessageTarget::Channel
));
}
#[tokio::test]
async fn empty_address_is_rejected() {
let s = ChanoraSession::new();
+25
View File
@@ -1695,6 +1695,8 @@ pub enum BridgeEvent {
message: String,
/// Target scope (server/channel/private/poke).
target: BridgeMessageTarget,
/// Poke notification strength, present only for poke messages.
poke_strength: Option<BridgePokeStrength>,
},
/// Human-readable server activity surfaced from protocol bookkeeping events.
ServerActivity {
@@ -1812,6 +1814,27 @@ impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
}
}
/// Bridge poke notification strength.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgePokeStrength {
/// Poke should be surfaced at full strength.
Strong,
/// Poke is rate-limited but below overflow severity.
Suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
SuppressedOverflow,
}
impl From<chanora_core::PokeStrength> for BridgePokeStrength {
fn from(strength: chanora_core::PokeStrength) -> Self {
match strength {
chanora_core::PokeStrength::Strong => Self::Strong,
chanora_core::PokeStrength::Suppressed => Self::Suppressed,
chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow,
}
}
}
/// Bridge mirror of core join projection sync state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinSyncState {
@@ -1958,11 +1981,13 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target: target.into(),
poke_strength: poke_strength.map(Into::into),
},
chanora_core::SessionEvent::ServerActivity { message } => {
BridgeEvent::ServerActivity { message }
@@ -2292,11 +2292,14 @@ impl SseDecode for crate::api::BridgeEvent {
let mut var_senderName = <String>::sse_decode(deserializer);
let mut var_message = <String>::sse_decode(deserializer);
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
let mut var_pokeStrength =
<Option<crate::api::BridgePokeStrength>>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChatMessage {
sender_id: var_senderId,
sender_name: var_senderName,
message: var_message,
target: var_target,
poke_strength: var_pokeStrength,
};
}
11 => {
@@ -2454,6 +2457,19 @@ impl SseDecode for crate::api::BridgeNetworkState {
}
}
impl SseDecode for crate::api::BridgePokeStrength {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <i32>::sse_decode(deserializer);
return match inner {
0 => crate::api::BridgePokeStrength::Strong,
1 => crate::api::BridgePokeStrength::Suppressed,
2 => crate::api::BridgePokeStrength::SuppressedOverflow,
_ => unreachable!("Invalid variant for BridgePokeStrength: {}", inner),
};
}
}
impl SseDecode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2680,6 +2696,17 @@ impl SseDecode for Option<String> {
}
}
impl SseDecode for Option<crate::api::BridgePokeStrength> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<crate::api::BridgePokeStrength>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -3299,12 +3326,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => [
10.into_dart(),
sender_id.into_into_dart().into_dart(),
sender_name.into_into_dart().into_dart(),
message.into_into_dart().into_dart(),
target.into_into_dart().into_dart(),
poke_strength.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ServerActivity { message } => {
@@ -3484,6 +3513,28 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePokeStrength {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
Self::Strong => 0.into_dart(),
Self::Suppressed => 1.into_dart(),
Self::SuppressedOverflow => 2.into_dart(),
_ => unreachable!(),
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgePokeStrength
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePokeStrength>
for crate::api::BridgePokeStrength
{
fn into_into_dart(self) -> crate::api::BridgePokeStrength {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -4053,12 +4104,14 @@ impl SseEncode for crate::api::BridgeEvent {
sender_name,
message,
target,
poke_strength,
} => {
<i32>::sse_encode(10, serializer);
<u64>::sse_encode(sender_id, serializer);
<String>::sse_encode(sender_name, serializer);
<String>::sse_encode(message, serializer);
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
<Option<crate::api::BridgePokeStrength>>::sse_encode(poke_strength, serializer);
}
crate::api::BridgeEvent::ServerActivity { message } => {
<i32>::sse_encode(11, serializer);
@@ -4214,6 +4267,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
}
}
impl SseEncode for crate::api::BridgePokeStrength {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(
match self {
crate::api::BridgePokeStrength::Strong => 0,
crate::api::BridgePokeStrength::Suppressed => 1,
crate::api::BridgePokeStrength::SuppressedOverflow => 2,
_ => {
unimplemented!("");
}
},
serializer,
);
}
}
impl SseEncode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -4429,6 +4499,16 @@ impl SseEncode for Option<String> {
}
}
impl SseEncode for Option<crate::api::BridgePokeStrength> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<crate::api::BridgePokeStrength>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+35 -8
View File
@@ -41,6 +41,7 @@ use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ProtocolDelta, ServerActivity, ServerSnapshot,
};
use crate::poke_limiter::PokeLimiter;
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
@@ -713,6 +714,7 @@ async fn connection_task(
// reply channel — at most 3 s of pending state per move.
let mut pending_moves: PendingMoves = HashMap::new();
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
let mut poke_limiter = PokeLimiter::new();
// Main loop: pump events, service requests, forward voice.
loop {
@@ -742,6 +744,7 @@ async fn connection_task(
&channels.activity,
&channels.delta,
&mut pending_moves,
&mut poke_limiter,
),
},
Ok(Some(Err(e))) => {
@@ -841,6 +844,7 @@ async fn connection_task(
&channels,
&mut pending_moves,
&mut voice_activity,
&mut poke_limiter,
)
.await;
let _ = reply.send(r);
@@ -903,6 +907,7 @@ fn handle_non_audio_stream_item(
activity_tx: &mpsc::Sender<ServerActivity>,
delta_tx: &mpsc::Sender<ProtocolDelta>,
pending_moves: &mut PendingMoves,
poke_limiter: &mut PokeLimiter,
) {
match item {
StreamItem::BookEvents(events) => {
@@ -964,19 +969,25 @@ fn handle_non_audio_stream_item(
message,
} = ev
{
let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
let (mapped, poke_strength) = match target {
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
tsclientlib::MessageTarget::Client(id) => {
MessageTarget::Client(id.0 as u64)
(MessageTarget::Client(id.0 as u64), None)
}
tsclientlib::MessageTarget::Poke(id) => {
let own_client_id =
con.get_state().ok().map(|state| state.own_client.0 as u64);
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
(MessageTarget::Poke(id.0 as u64), Some(strength))
}
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
};
let _ = chat_tx.try_send(ChatMessage {
sender_id: ClientId(invoker.id.0 as u64),
sender_name: sanitize(&invoker.name),
message: sanitize(&message),
target: mapped,
poke_strength,
});
}
}
@@ -1164,6 +1175,7 @@ async fn fetch_client_profile(
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<ClientProfile, ProtocolError> {
let target_id = TsClientId(client_id as u16);
@@ -1209,6 +1221,7 @@ async fn fetch_client_profile(
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await;
}
@@ -1219,6 +1232,7 @@ async fn fetch_client_profile(
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await;
}
@@ -1233,6 +1247,7 @@ async fn fetch_client_profile(
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await
{
@@ -1251,6 +1266,7 @@ async fn fetch_client_profile(
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await
{
@@ -1264,9 +1280,16 @@ async fn fetch_client_profile(
}
let db_info = if refresh_plan.needs_client_db_info {
request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
.await
.ok()
request_client_db_info(
con,
database_id,
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await
.ok()
} else {
None
};
@@ -1426,6 +1449,7 @@ async fn request_messages(
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<Vec<InMessage>, ProtocolError> {
let handle = command
.send_with_result(con)
@@ -1468,6 +1492,7 @@ async fn request_messages(
&channels.activity,
&channels.delta,
pending_moves,
poke_limiter,
),
}
}
@@ -1479,6 +1504,7 @@ async fn request_client_db_info(
channels: &EventChannels,
pending_moves: &mut PendingMoves,
voice_activity: &mut HashMap<u64, Instant>,
poke_limiter: &mut PokeLimiter,
) -> Result<InClientDbInfoPart, ProtocolError> {
let messages = request_messages(
con,
@@ -1486,6 +1512,7 @@ async fn request_client_db_info(
channels,
pending_moves,
voice_activity,
poke_limiter,
)
.await?;
for message in messages {
+4
View File
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize};
pub use crate::poke_limiter::PokeStrength;
/// Opaque server-side channel identifier. Internal representation is
/// the upstream u64 but callers must treat it as opaque.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -54,6 +56,8 @@ pub struct ChatMessage {
pub message: String,
/// Target scope of this message.
pub target: MessageTarget,
/// Strength classification for poke notifications.
pub poke_strength: Option<PokeStrength>,
}
/// A server-activity notification derived from TeamSpeak bookkeeping events.
+3 -1
View File
@@ -35,12 +35,14 @@
mod adapter;
mod dto;
pub mod poke_limiter;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
ProtocolDelta, ServerActivity, ServerSnapshot,
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
};
pub use poke_limiter::PokeLimiter;
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /
+174
View File
@@ -0,0 +1,174 @@
//! Per-connection poke strength classification.
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Notification strength assigned to an inbound poke.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PokeStrength {
/// Poke should be surfaced at full strength.
Strong,
/// Poke is rate-limited but below overflow severity.
Suppressed,
/// Poke remains suppressed after repeated suppressed pokes.
SuppressedOverflow,
}
/// Per-connection poke limiter.
#[derive(Debug)]
pub struct PokeLimiter {
window: Duration,
entries: HashMap<u64, PokeEntry>,
}
#[derive(Debug)]
struct PokeEntry {
tokens: u8,
last_refill: Instant,
suppressed_in_window: u8,
}
impl PokeLimiter {
const CAPACITY: u8 = 2;
const OVERFLOW_THRESHOLD: u8 = 3;
/// Create a limiter using the default five-minute refill interval.
pub fn new() -> Self {
Self {
window: Duration::from_secs(5 * 60),
entries: HashMap::new(),
}
}
/// Record a poke at the current instant.
pub fn record(&mut self, sender_id: u64, own_client_id: Option<u64>) -> PokeStrength {
self.record_at(sender_id, own_client_id, Instant::now())
}
/// Record a poke at an injected instant.
pub fn record_at(
&mut self,
sender_id: u64,
own_client_id: Option<u64>,
now: Instant,
) -> PokeStrength {
if own_client_id == Some(sender_id) {
return PokeStrength::Suppressed;
}
let entry = self.entries.entry(sender_id).or_insert(PokeEntry {
tokens: Self::CAPACITY,
last_refill: now,
suppressed_in_window: 0,
});
if now.duration_since(entry.last_refill) >= self.window {
entry.tokens = Self::CAPACITY;
entry.last_refill = now;
entry.suppressed_in_window = 0;
}
if entry.tokens > 0 {
entry.tokens -= 1;
return PokeStrength::Strong;
}
entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1);
if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD {
PokeStrength::SuppressedOverflow
} else {
PokeStrength::Suppressed
}
}
}
impl Default for PokeLimiter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_two_pokes_are_strong_third_is_suppressed() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
}
#[test]
fn self_poke_is_suppressed_without_consuming_token() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Strong
);
}
#[test]
fn overflow_after_three_suppressed_pokes_in_five_minutes() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(3)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(4)),
PokeStrength::SuppressedOverflow
);
}
#[test]
fn refill_after_interval_uses_injected_time_without_sleeping() {
let mut limiter = PokeLimiter::new();
let now = Instant::now();
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
PokeStrength::Suppressed
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)),
PokeStrength::Strong
);
assert_eq!(
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)),
PokeStrength::Strong
);
}
}
+1
View File
@@ -615,6 +615,7 @@ mod tests {
sender_name: "Alice".into(),
message: "hello".into(),
target: MessageTarget::Channel,
poke_strength: None,
};
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
assert!(disconnected.deltas.is_empty());
+193
View File
@@ -49,6 +49,11 @@ terms.
| `flutter` | 0.0.0 | sdk | yes |
| `flutter_foreground_task` | 9.2.2 | hosted | yes |
| `flutter_lints` | 6.0.0 | hosted | yes |
| `flutter_local_notifications` | 22.0.0 | hosted | yes |
| `flutter_local_notifications_linux` | 8.0.1 | hosted | yes |
| `flutter_local_notifications_platform_interface` | 12.0.0 | hosted | yes |
| `flutter_local_notifications_web` | 1.0.0 | hosted | yes |
| `flutter_local_notifications_windows` | 3.1.0 | hosted | yes |
| `flutter_localizations` | 0.0.0 | sdk | yes |
| `flutter_rust_bridge` | 2.12.0 | hosted | yes |
| `flutter_test` | 0.0.0 | sdk | yes |
@@ -117,6 +122,7 @@ terms.
| `string_scanner` | 1.4.1 | hosted | yes |
| `term_glyph` | 1.2.2 | hosted | yes |
| `test_api` | 0.7.11 | hosted | yes |
| `timezone` | 0.11.0 | hosted | yes |
| `typed_data` | 1.4.0 | hosted | yes |
| `url_launcher` | 6.3.2 | hosted | yes |
| `url_launcher_android` | 6.3.30 | hosted | yes |
@@ -1869,6 +1875,166 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications 22.0.0
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_linux 8.0.1
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_platform_interface 12.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_web 1.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_windows 3.1.0
```
Copyright 2024 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_localizations 0.0.0
```
@@ -4673,6 +4839,33 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### timezone 0.11.0
```
Copyright (c) 2014, timezone project authors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### typed_data 1.4.0
```
+1 -1
View File
@@ -291,7 +291,7 @@ those terms.
| chanora_diagnostics | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_prefetch | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_protocol | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_resolver | 0.1.0 | `Apache License 2.0` | — |
| chanora_resolver | 0.2.0-beta.1 | `Apache License 2.0` | — |
| chanora_state | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_storage | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| alsa | 0.11.0 | `Apache License 2.0` | <https://github.com/diwic/alsa-rs> |
@@ -0,0 +1,176 @@
# Poke Without Message Design
**Date:** 2026-06-09
**Status:** Approved design for implementation
**Scope:** Allow intentional TeamSpeak-compatible pokes without message text while preserving empty-message blocking for normal chat targets.
## 1. Goal
Chanora should let a user poke another connected client without typing a message. A poke is an attention event, not an empty chat message. The UI should make that distinction explicit so the empty state is intentional, understandable, and safe from accidental spam.
The implementation target is narrow:
- Sending a poke with an empty message is allowed.
- Sending an empty normal chat message remains blocked.
- Incoming and historical empty pokes continue to render as poke events, not blank chat bubbles.
- Existing poke notification behavior remains compatible with message and no-message pokes.
## 2. Research Summary
TeamSpeak-compatible poke behavior is command-like: the ServerQuery shape is `clientpoke clid={clientID} msg={text}`, backed by poke permissions such as `i_client_poke_power` and `i_client_needed_poke_power`. The product semantics are closer to an attention nudge than to a private text message.
Client behavior and community expectations point to two UX risks:
- The action can be useful without text because the sender often only wants attention.
- The action can be abused as interruption spam, so the UI must keep the action deliberate and preserve existing receiver-side suppression and notification preferences.
The approved product direction is therefore to model no-message poke as a first-class attention event with optional text, rather than as an exception in the normal chat composer.
## 3. Recommended UX
Poke uses a poke-specific sending surface. The surface may reuse the current chat detail implementation internally, but the user-facing copy and validation must make the target type clear.
Required poke-target behavior:
| Element | Behavior |
|---|---|
| Header | Shows that the current surface is for poking the selected user. |
| Text field | Optional message input. Placeholder should communicate that the message is optional. |
| Primary action | Label is `Poke`, not `Send`. Enabled even when the trimmed message is empty. |
| Empty send | Sends an intentional poke with `message: ''`. |
| Non-empty send | Sends a poke with the typed message. |
| History row | Empty poke renders as an attention event such as `Alice poked you`, never as a blank message. |
Required non-poke chat behavior:
| Target | Empty text behavior |
|---|---|
| Channel chat | Block send. |
| Server chat | Block send. |
| Private chat | Block send. |
| Any future text-chat target | Block send unless it is explicitly modeled as a poke-like attention event. |
## 4. Architecture Boundaries
The change should stay inside the existing UI and bridge boundaries:
- Flutter owns presentation, composer validation, button enablement, localization copy, and widget tests.
- Flutter Rust Bridge continues to pass typed `BridgeMessageTarget` and message text across the bridge.
- Rust Core and Protocol continue to route `MessageTarget::Poke(client_id)` through the existing poke send path.
- Protocol remains the only layer that knows how `tsclientlib` sends a TeamSpeak-compatible poke.
No new protocol concept is required. The existing bridge/protocol model already has `BridgeMessageTarget.poke` / `MessageTarget::Poke(u64)` and `client.poke(message)`. The key design change is target-aware composer validation in Flutter.
## 5. Implementation Design
The implementation should use a target-aware send policy.
For `BridgeMessageTarget.poke`:
- Do not reject an empty trimmed input.
- Send the original or trimmed message according to the existing chat composer convention. If the current send path trims normal messages before sending, apply the same text normalization before passing the poke message.
- Clear the composer after successful send, including empty-poke sends.
- Preserve existing error and snackbar behavior for failed sends.
For all other `BridgeMessageTarget` variants:
- Keep the existing empty-trimmed-text guard.
- Keep current button enablement and keyboard submit behavior unless those paths need target-aware adjustment to preserve the same empty-message block.
A simple policy helper is preferred over scattered conditionals. Example shape:
```dart
bool canSendMessage({
required BridgeMessageTarget target,
required String text,
}) {
if (target is BridgeMessageTarget_Poke) {
return true;
}
return text.trim().isNotEmpty;
}
```
The exact Dart type checks should follow the generated bridge type names used in the current codebase.
## 6. Notification And History Behavior
Existing no-message receiving behavior should remain the reference behavior:
- Incoming empty poke notification body falls back to text equivalent to `Alice pokes you`.
- Incoming poke with message includes the message in the notification body.
- Active-chat suppression and muted-sender preferences continue to apply.
- Poke history rows distinguish poke events from normal chat rows.
The send-side change must not introduce a new blank message row shape. If the sender's local history records sent pokes, empty poke history should render as a poke action line with no empty bubble.
## 7. Abuse And Safety Rules
This slice does not add new anti-spam controls. It relies on existing TeamSpeak-compatible permissions, inbound poke strength/rate suppression, notification preferences, active-chat suppression, and muted sender handling.
The implementation must not weaken any existing receiver-side controls. If testing reveals that empty sent pokes bypass suppression, notification preferences, or history classification, that is a bug to fix in the same implementation pass.
Future follow-ups, not part of this slice:
- Per-sender or per-server outbound poke cooldown UI.
- Receiver-side "never show poke dialog" equivalent beyond current notification preferences.
- Dedicated poke inbox or grouped poke history.
## 8. Files Expected To Change
Expected implementation targets:
| File | Expected change |
|---|---|
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | Make composer validation and action enablement target-aware for poke. Update poke placeholder/action copy if needed. |
| `apps/chanora_flutter/test/widgets/chat_views_test.dart` | Add widget coverage for empty poke send and normal empty chat blocking. |
Optional targets if the implementation exposes missing copy or routing seams:
| File | Possible change |
|---|---|
| `apps/chanora_flutter/lib/main.dart` | Only if opening a poke target needs a clearer poke-specific title or route configuration. |
| `apps/chanora_flutter/lib/l10n/*.arb` | Only if current copy cannot express optional poke messages without hard-coded strings. |
| `apps/chanora_flutter/test/services/poke_notification_service_test.dart` | Only if send-side changes affect notification payload assumptions. |
The Rust protocol path should not need behavior changes unless tests prove that empty strings are blocked below Flutter.
## 9. Test Design
Required tests:
- Poke target shows an enabled primary `Poke` action when the text field is empty.
- Tapping `Poke` on an empty poke target calls the send callback with `BridgeMessageTarget.poke` and an empty message.
- Poke target still sends a typed message when text is present.
- Normal channel/server/private chat targets keep blocking empty sends.
- Empty poke history renders as a poke event line, not an empty text bubble.
Useful regression checks if already easy to target:
- Keyboard submit follows the same target-aware validation as the button.
- Failed empty-poke send keeps existing error presentation.
- Incoming empty poke notification tests still pass unchanged.
## 10. Validation
For the implementation branch, run focused Flutter verification first:
```text
flutter test test/widgets/chat_views_test.dart
flutter test test/services/poke_notification_service_test.dart
flutter test test/services/poke_active_chat_test.dart
flutter analyze
```
If Rust or bridge files are touched, also run the matching Rust and bridge checks for the touched layer. Documentation-only changes require reading the affected spec and checking the diff; code tests are not required for this design commit.
## 11. Success Criteria
This design is implemented successfully when:
- A user can send a poke with no typed message.
- Normal chat targets still reject empty sends.
- The poke composer communicates that message text is optional.
- Empty pokes are represented as poke events in history and notifications.
- Existing poke notification preferences and suppression behavior remain intact.
- Focused widget/service tests and `flutter analyze` pass, or any unrelated pre-existing failure is named with evidence.