From b565663645e05c446913532ff565bb0b1db13d74 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:56:52 +0900 Subject: [PATCH] feat(chat): route pokes through notifications --- apps/chanora_flutter/lib/main.dart | 245 +++++++----------- .../test/services/poke_active_chat_test.dart | 48 ++++ 2 files changed, 148 insertions(+), 145 deletions(-) create mode 100644 apps/chanora_flutter/test/services/poke_active_chat_test.dart 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/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, + ); + }); +}