feat(chat): route pokes through notifications
This commit is contained in:
+100
-145
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user