diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts
index 5f0fb16..1e1cb9d 100644
--- a/apps/chanora_flutter/android/app/build.gradle.kts
+++ b/apps/chanora_flutter/android/app/build.gradle.kts
@@ -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 {
diff --git a/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml b/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml
new file mode 100644
index 0000000..6735a70
--- /dev/null
+++ b/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml b/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml
new file mode 100644
index 0000000..a4a69b8
--- /dev/null
+++ b/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/apps/chanora_flutter/ios/Runner/Info.plist b/apps/chanora_flutter/ios/Runner/Info.plist
index b97e12f..5364474 100644
--- a/apps/chanora_flutter/ios/Runner/Info.plist
+++ b/apps/chanora_flutter/ios/Runner/Info.plist
@@ -34,6 +34,8 @@
Chanora needs local network access to connect to your voice servers.
NSMicrophoneUsageDescription
Chanora needs microphone access so you can talk on your voice server.
+ NSUserNotificationsUsageDescription
+ Chanora sends you a notification when another user pokes you.
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb
index 91c8b22..2cda470 100644
--- a/apps/chanora_flutter/lib/l10n/app_en.arb
+++ b/apps/chanora_flutter/lib/l10n/app_en.arb
@@ -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}\".",
diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb
index 87388fc..d9864df 100644
--- a/apps/chanora_flutter/lib/l10n/app_zh.arb
+++ b/apps/chanora_flutter/lib/l10n/app_zh.arb
@@ -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}”一下。",
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
index 13bc179..8c53ef9 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart
@@ -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.
///
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
index e77422c..d8c4241 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart
@@ -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
diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
index 1c5fbd2..8382d60 100644
--- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
+++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart
@@ -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
diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart
index d8f51ca..f73d422 100644
--- a/apps/chanora_flutter/lib/main.dart
+++ b/apps/chanora_flutter/lib/main.dart
@@ -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 {
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> _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 _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 _onOpenPokeSettings() async {
+ await _pokePreferences.load();
+ if (!mounted) return;
+ await showDialog(
+ context: context,
+ builder: (ctx) =>
+ PokeNotificationSettingsDialog(preferences: _pokePreferences),
+ );
+ }
+
Future _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 _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> pokes;
-
- @override
- Widget build(BuildContext context) {
- return ValueListenableBuilder>(
- 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)}';
-}
diff --git a/apps/chanora_flutter/lib/services/poke_notification_service.dart b/apps/chanora_flutter/lib/services/poke_notification_service.dart
new file mode 100644
index 0000000..2cd1168
--- /dev/null
+++ b/apps/chanora_flutter/lib/services/poke_notification_service.dart
@@ -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 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 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 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,
+ );
+ }
+}
diff --git a/apps/chanora_flutter/lib/services/poke_preferences_service.dart b/apps/chanora_flutter/lib/services/poke_preferences_service.dart
new file mode 100644
index 0000000..7e930a7
--- /dev/null
+++ b/apps/chanora_flutter/lib/services/poke_preferences_service.dart
@@ -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 _pokesEnabled = ValueNotifier(true);
+ final ValueNotifier> _mutedSenders = ValueNotifier>(
+ const {},
+ );
+
+ ValueListenable get pokesEnabled => _pokesEnabled;
+ ValueListenable> get mutedSenders => _mutedSenders;
+
+ Future load() async {
+ final prefs = await SharedPreferences.getInstance();
+ _pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
+ _mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
+ .map(BigInt.parse)
+ .toSet();
+ }
+
+ Future setPokesEnabled(bool enabled) async {
+ _pokesEnabled.value = enabled;
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_enabledKey, enabled);
+ }
+
+ Future muteSender(BigInt senderId) async {
+ if (_mutedSenders.value.contains(senderId)) return;
+ _mutedSenders.value = {..._mutedSenders.value, senderId};
+ await _saveMutedSenders();
+ }
+
+ Future 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 _saveMutedSenders() async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setStringList(
+ _mutedSendersKey,
+ _mutedSenders.value.map((senderId) => senderId.toString()).toList(),
+ );
+ }
+
+ void dispose() {
+ _pokesEnabled.dispose();
+ _mutedSenders.dispose();
+ }
+}
diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart
index b0e13e6..57aaef5 100644
--- a/apps/chanora_flutter/lib/src/rust/api.dart
+++ b/apps/chanora_flutter/lib/src/rust/api.dart
@@ -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 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 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
diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart
index 08eb80f..f49b1e8 100644
--- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart
+++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart
@@ -173,7 +173,7 @@ return channelUpdated(_that);case _:
/// }
/// ```
-@optionalTypeArgs TResult maybeWhen({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 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({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({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? 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? 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 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
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart
index c662636..eeb9886 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart
@@ -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,
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
index a4cfb75..d6cad6c 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart
@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@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 {
@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 {
@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 {
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 {
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 {
@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 {
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 {
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 {
@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,
diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
index 9b269ba..5d9c328 100644
--- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
+++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart
@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl {
@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 {
@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 {
@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 {
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 {
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 {
@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 {
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 {
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 {
@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,
diff --git a/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart b/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart
new file mode 100644
index 0000000..f876552
--- /dev/null
+++ b/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart
@@ -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(
+ 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>(
+ 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),
+ ),
+ ],
+ );
+ }
+}
diff --git a/apps/chanora_flutter/macos/Runner/Info.plist b/apps/chanora_flutter/macos/Runner/Info.plist
index 3a96692..4209052 100644
--- a/apps/chanora_flutter/macos/Runner/Info.plist
+++ b/apps/chanora_flutter/macos/Runner/Info.plist
@@ -42,6 +42,8 @@
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.
NSLocalNetworkUsageDescription
Chanora needs local network access to connect to TeamSpeak-compatible voice servers.
+ NSUserNotificationsUsageDescription
+ Chanora sends you a notification when another user pokes you.
NSBonjourServices
_ts3._tcp
diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock
index 43275f1..8b88c9d 100644
--- a/apps/chanora_flutter/pubspec.lock
+++ b/apps/chanora_flutter/pubspec.lock
@@ -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:
diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml
index 5be734b..350b3a0 100644
--- a/apps/chanora_flutter/pubspec.yaml
+++ b/apps/chanora_flutter/pubspec.yaml
@@ -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
diff --git a/apps/chanora_flutter/test/services/poke_active_chat_test.dart b/apps/chanora_flutter/test/services/poke_active_chat_test.dart
new file mode 100644
index 0000000..7f48092
--- /dev/null
+++ b/apps/chanora_flutter/test/services/poke_active_chat_test.dart
@@ -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,
+ );
+ });
+}
diff --git a/apps/chanora_flutter/test/services/poke_notification_service_test.dart b/apps/chanora_flutter/test/services/poke_notification_service_test.dart
new file mode 100644
index 0000000..d356be3
--- /dev/null
+++ b/apps/chanora_flutter/test/services/poke_notification_service_test.dart
@@ -0,0 +1,63 @@
+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 calls;
+
+ setUp(() {
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ AndroidFlutterLocalNotificationsPlugin.registerWith();
+ calls = [];
+ 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