feat(chat): route pokes through notifications

This commit is contained in:
Edison Jwa
2026-06-08 22:56:52 +09:00
parent 34a5247457
commit b565663645
2 changed files with 148 additions and 145 deletions
+100 -145
View File
@@ -28,6 +28,8 @@ import 'services/connection_phase_state.dart';
import 'services/hard_mute_owners.dart';
import 'services/ios_permissions_service.dart';
import 'services/macos_permissions_service.dart';
import 'services/poke_notification_service.dart';
import 'services/poke_preferences_service.dart';
import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
@@ -43,6 +45,7 @@ import 'widgets/client_info_sheet.dart';
import 'widgets/connect_widgets.dart';
import 'widgets/input_dialogs.dart';
import 'widgets/permission_state_banner.dart';
import 'widgets/poke_notification_settings.dart';
import 'widgets/snapshot_view.dart';
import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
@@ -161,10 +164,7 @@ class _ChanoraAppState extends State<ChanoraApp> {
supportedLocales: AppL10n.supportedLocales,
home: Stack(
children: [
_BetaHome(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
_BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode),
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
],
),
@@ -202,6 +202,19 @@ extension on ThemeMode {
}
}
bool isPokeSenderActiveChat({
required bool chatOpen,
required rust.BridgeMessageTarget? inlineChatTarget,
required BigInt senderId,
}) {
if (!chatOpen) return false;
return switch (inlineChatTarget) {
rust.BridgeMessageTarget_Poke(:final field0) ||
rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId,
_ => false,
};
}
class ChanoraThemeModeMenu extends StatelessWidget {
const ChanoraThemeModeMenu({
super.key,
@@ -302,18 +315,6 @@ class _BetaHome extends StatefulWidget {
State<_BetaHome> createState() => _BetaHomeState();
}
class _ReceivedPoke {
const _ReceivedPoke({
required this.senderName,
required this.message,
required this.receivedAt,
});
final String senderName;
final String message;
final DateTime receivedAt;
}
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
@@ -412,11 +413,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
/// previous conversation when the user reopens chat.
rust.BridgeMessageTarget? _lastDismissedTarget;
String _lastDismissedClientName = '';
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeSnackBarVisible = false;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
// Constructed at startup so cold-launch state is captured before the
// first voice_join attempt. On non-Android hosts the service
@@ -431,6 +427,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// MethodChannel.
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
final UiPreferencesService _uiPreferences = const UiPreferencesService();
final PokeNotificationService _pokeNotifications = PokeNotificationService();
final PokePreferencesService _pokePreferences = PokePreferencesService();
late final Future<void> _pokePreferencesReady;
@override
void initState() {
@@ -464,6 +463,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_onMacOSPttCapabilityChanged,
);
_macOSPermissions.checkInitialStates();
unawaited(_pokeNotifications.init());
_pokePreferencesReady = _pokePreferences.load();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_requestRecordAudioOnStartup());
});
@@ -867,10 +868,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
:final senderName,
:final message,
:final target,
:final pokeStrength,
):
// Skip echo of self-sent messages (already added locally).
if (senderId == _snapshot?.ownClientId) return;
final isPoke = target is rust.BridgeMessageTarget_Poke;
// Skip echo of self-sent non-poke messages (already added locally).
if (!isPoke && senderId == _snapshot?.ownClientId) return;
final receivedAt = DateTime.now();
setState(() {
_appendChatEntryUnlocked(
@@ -885,10 +887,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
});
if (isPoke) {
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
unawaited(
_handleIncomingPoke(
senderId: senderId,
senderName: senderName,
message: message,
strength: pokeStrength,
),
);
return;
}
@@ -1088,7 +1093,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_nickCtl.dispose();
_passwordCtl.dispose();
_chatFeedRevision.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -1105,6 +1109,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_androidPermissions.stop();
_iosPermissions.stop();
_macOSPermissions.stop();
_pokePreferences.dispose();
super.dispose();
}
@@ -1138,7 +1143,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
if (accessState == MacOSLocalNetworkState.denied) {
if (!mounted) return;
setState(() { _phase = ConnectionPhase.idle; });
setState(() {
_phase = ConnectionPhase.idle;
});
_showLocalNetworkDeniedSnackBar();
return;
}
@@ -1520,6 +1527,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
Future<void> _onOpenPokeSettings() async {
await _pokePreferences.load();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) =>
PokeNotificationSettingsDialog(preferences: _pokePreferences),
);
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
// Same pattern as _onAddCurrentBookmark: route the dialog
// through a dedicated StatefulWidget so its
@@ -1804,9 +1821,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _inlineChatTarget == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
),
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
);
});
}
@@ -1827,52 +1842,68 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
}
void _showPokeSnackBar({
Future<void> _handleIncomingPoke({
required BigInt senderId,
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
required rust.BridgePokeStrength? strength,
}) async {
if (senderId == _snapshot?.ownClientId) return;
await _pokePreferencesReady;
if (!_pokePreferences.pokesEnabled.value) return;
if (_pokePreferences.isMuted(senderId)) return;
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
}
if (_isPokeSenderActiveChat(senderId)) return;
await _pokeNotifications.show(
senderName: senderName,
message: message,
senderId: senderId,
strength: pokeStrength,
);
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
bool _isPokeSenderActiveChat(BigInt senderId) {
return isPokeSenderActiveChat(
chatOpen: _chatOpen,
inlineChatTarget: _inlineChatTarget,
senderId: senderId,
);
}
void _showPokeOverflowMutePrompt({
required BigInt senderId,
required String senderName,
}) {
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
duration: const Duration(seconds: 8),
content: Text(
l10n.pokeOverflowMutePrompt(senderName),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
label: l10n.pokeOverflowMuteAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
unawaited(_pokePreferences.muteSender(senderId));
messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
),
);
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1880,7 +1911,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -2377,6 +2407,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
themeMode: widget.themeMode,
onThemeModeChanged: widget.onThemeModeChanged,
),
IconButton(
tooltip: l10n.pokeSettingsAction,
icon: const Icon(Icons.notifications_outlined),
onPressed: () => unawaited(_onOpenPokeSettings()),
),
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
@@ -2856,83 +2891,3 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
);
}
}
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
);
}
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
@@ -0,0 +1,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,
);
});
}