From e395e3320fe13609ecaba36aac009b15a415cdb6 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 08:32:01 +0100 Subject: [PATCH] Polish mobile Huddles calls Signed-off-by: kenny lopez --- mobile/HUDDLES.md | 33 +- mobile/ios/Runner/AppDelegate.swift | 2 +- .../ios/Runner/ConcentricSheetSurface.swift | 75 +++- .../channels/channel_detail_page.dart | 7 +- .../huddle_call_avatar.dart | 107 +++-- .../huddle_call_controls.dart | 17 +- .../huddle_call_participants.dart | 194 +++++++-- .../channel_detail_page/huddle_drawer.dart | 150 ++++--- .../channel_detail_page/huddle_reactions.dart | 117 ++++++ .../channel_detail_page/huddle_sheet.dart | 50 +++ .../channels/channel_huddle_actions.dart | 34 ++ .../channels/mobile_huddle_controller.dart | 18 +- mobile/lib/shared/emoji/emoji_burst.dart | 6 + mobile/lib/shared/huddle/huddle_session.dart | 68 ++- mobile/lib/shared/theme/app_theme.dart | 5 +- .../widgets/concentric_sheet_surface.dart | 56 ++- .../channels/channel_detail_page_test.dart | 396 +++++++++++++++++- .../channel_management_provider_test.dart | 85 ++++ .../shared/huddle/huddle_session_test.dart | 8 + mobile/test/shared/theme/app_theme_test.dart | 13 + .../widgets/modal_presentation_test.dart | 91 ++++ 21 files changed, 1355 insertions(+), 177 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/huddle_reactions.dart diff --git a/mobile/HUDDLES.md b/mobile/HUDDLES.md index fb716ced4..01082b2b7 100644 --- a/mobile/HUDDLES.md +++ b/mobile/HUDDLES.md @@ -9,10 +9,11 @@ The parent channel carries creator-signed kind `48100` start and `48103` end events. The private `stream` backing channel uses kind `9007`, `ttl=3600`, and is hidden from the ordinary channel list only after a matching start event links it from an accessible parent. The top-right hang-up follows Desktop's -rule: it counts non-`bot` backing-channel members before disconnecting, submits -kind `9022` when another human remains, and otherwise publishes kind `48103` -and archives with kind `9002`. A failed count safely assumes another human is -present so a transient relay error cannot end the Huddle. Creator-only explicit +rule: it fetches the current kind `39002` backing-channel snapshot and counts +non-`bot` members before disconnecting, submits kind `9022` when another human +remains, and otherwise publishes kind `48103` and archives with kind `9002`. +A failed count safely assumes another human is present so a transient relay +error cannot end the Huddle. Creator-only explicit end support remains in the controller, but the current mobile UI does not foreground a separate β€œEnd for everyone” control. On relaunch, parent event history reconstructs the visible card without silently reopening a microphone. @@ -34,6 +35,17 @@ history reconstructs the visible card without silently reopening a microphone. The main Nostr socket is intentionally not shared: it accepts Nostr JSON arrays and drops binary frames, while Huddle control messages are JSON objects. +Emoji reactions remain on the shared Nostr control plane. Mobile publishes the +same ephemeral kind `24810` event as Desktop, scoped to the backing channel with +`h`, `reaction`, and `sender_name` tags (plus the optional NIP-30 `emoji` tag for +custom emoji). While a Huddle session is active, Mobile also subscribes to that +same backing-channel event stream and bursts reactions from other identities; +the sender's relay echo is ignored because its burst already plays locally. +When the sender's full-screen participant avatar is visible, that avatar is the +burst origin so authorship is spatially clear; minimized calls fall back to the +available Huddle surface. Reaction events never enter the ordinary channel +timeline. + ## Media plane: protocol v2 Audio is 48 kHz, mono Opus in 20 ms (960-sample) frames. @@ -55,6 +67,13 @@ level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. Unknown flag bits are ignored. Frames are capped at 4096 bytes before the relay peer prefix. +The dBov header also drives the participant speaking treatment. Values above +the existing `-55 dBov` activity threshold are normalized for a 50 ms visual +update cadence and continuously scale a 7%-opacity primary-color halo from +just beyond the avatar to 2.55 times its base diameter. Level increases use a +responsive eased attack while decreases and silence use a softer eased release; +silence begins that release after the existing 600 ms hold. + ## Native boundary `buzz/huddle_media` owns microphone permission and the foreground voice audio @@ -85,9 +104,9 @@ cannot accumulate unbounded latency. This is deliberately not Desktop parity. The app must remain in the foreground; pausing/detaching leaves the room. Mobile agent controls, mobile-originated -STT/TTS, recordings, reactions, background ringing/calling, and advanced device -controls remain outside this slice. Agent-authenticated Opus published by a -Desktop Huddle uses the same remote playback path as any other participant. +STT/TTS, recordings, background ringing/calling, and advanced device controls +remain outside this slice. Agent-authenticated Opus published by a Desktop +Huddle uses the same remote playback path as any other participant. Physical iOS acceptance must separately verify permission, receiver/speaker and connected-headset routing, interruption recovery, and two-way audio with a diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 924ba5d26..2d719066e 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -70,7 +70,7 @@ import UserNotifications forPlugin: "BuzzConcentricSheetSurface" ) { concentricSheetRegistrar.register( - ConcentricSheetSurfaceFactory(), + ConcentricSheetSurfaceFactory(messenger: messenger), withId: "buzz/concentric_sheet_surface" ) concentricSheetSurfaceChannel = FlutterMethodChannel( diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift index 379633b37..a1da7650a 100644 --- a/mobile/ios/Runner/ConcentricSheetSurface.swift +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -2,6 +2,13 @@ import Flutter import UIKit final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { FlutterStandardMessageCodec.sharedInstance() } @@ -11,24 +18,48 @@ final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory viewIdentifier viewId: Int64, arguments args: Any? ) -> FlutterPlatformView { - ConcentricSheetSurfacePlatformView(frame: frame, arguments: args) + ConcentricSheetSurfacePlatformView( + frame: frame, + viewIdentifier: viewId, + messenger: messenger, + arguments: args + ) } } final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { + private let rootView: UIView private let surfaceView: UIView + private let channel: FlutterMethodChannel - init(frame: CGRect, arguments args: Any?) { + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger, + arguments args: Any? + ) { let arguments = args as? [String: Any] let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFF_FFFF + let backdropColorValue = (arguments?["backdropColor"] as? NSNumber)?.uint32Value let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24 let corners = arguments?["corners"] as? String ?? "all" - surfaceView = UIView(frame: frame) + rootView = UIView(frame: frame) + rootView.isOpaque = backdropColorValue != nil + rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear + + surfaceView = UIView(frame: rootView.bounds) surfaceView.isOpaque = true surfaceView.backgroundColor = Self.color(from: colorValue) surfaceView.clipsToBounds = true surfaceView.layer.cornerCurve = .continuous + surfaceView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + rootView.addSubview(surfaceView) + + channel = FlutterMethodChannel( + name: "buzz/concentric_sheet_surface/\(viewId)", + binaryMessenger: messenger + ) if #available(iOS 26.0, *) { let radius = UICornerRadius.containerConcentric(minimum: minimumRadius) @@ -51,10 +82,46 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { } super.init() + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "updateColors" else { + result(FlutterMethodNotImplemented) + return + } + guard + let arguments = call.arguments as? [String: Any], + let colorValue = arguments["color"] as? NSNumber + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a surface color.", + details: nil + ) + ) + return + } + + self?.updateColors( + colorValue: colorValue.uint32Value, + backdropColorValue: (arguments["backdropColor"] as? NSNumber)?.uint32Value + ) + result(nil) + } + } + + deinit { + channel.setMethodCallHandler(nil) } func view() -> UIView { - surfaceView + rootView + } + + private func updateColors(colorValue: UInt32, backdropColorValue: UInt32?) { + surfaceView.backgroundColor = Self.color(from: colorValue) + rootView.isOpaque = backdropColorValue != nil + rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear } private static func color(from value: UInt32) -> UIColor { diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 63d4274af..8850095c0 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:math' show min; +import 'dart:math' show cos, min, pi; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -10,6 +10,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/animated_avatar.dart'; +import '../../shared/emoji/emoji_burst.dart'; import '../../shared/huddle/huddle.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; @@ -19,6 +20,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/flapping_bee.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/message_author_meta.dart'; @@ -47,6 +49,7 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; +import 'emoji_picker.dart'; import 'ime_metrics_settle_observer.dart'; import 'latest_message_button.dart'; import 'mobile_huddle_controller.dart'; @@ -59,6 +62,7 @@ import '../../shared/read_state/read_state_format.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; import 'reaction_row.dart'; +import 'recent_emoji_provider.dart'; import 'send_message_provider.dart'; import '../profile/user_profile_sheet.dart'; import 'small_avatar.dart'; @@ -72,6 +76,7 @@ part 'channel_detail_page/huddle_call_avatar.dart'; part 'channel_detail_page/huddle_call_participants.dart'; part 'channel_detail_page/huddle_call_controls.dart'; part 'channel_detail_page/huddle_drawer.dart'; +part 'channel_detail_page/huddle_reactions.dart'; part 'channel_detail_page/message_bubble.dart'; part 'channel_detail_page/banners.dart'; part 'channel_detail_page/app_bar.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 8020743f5..c52d968fa 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -1,23 +1,61 @@ part of '../channel_detail_page.dart'; -class _HuddleCallAvatar extends HookWidget { +class _HuddleCallAvatar extends HookConsumerWidget { const _HuddleCallAvatar({ required this.pubkey, required this.profile, required this.fallbackLabel, required this.active, + required this.speakerLevel, this.isSelf = false, + this.frameSize = _huddleAvatarFrameSize, }); final String pubkey; final UserProfile? profile; final String? fallbackLabel; final bool active; + final double speakerLevel; final bool isSelf; + final double frameSize; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final reducedMotion = MediaQuery.disableAnimationsOf(context); + final scale = frameSize / _huddleAvatarFrameSize; + final avatarRadius = _huddleAvatarRadius * scale; + final speakingRingSize = _huddleSpeakingRingSize * scale; + final fallbackIconSize = 44 * scale; + final normalizedSpeakerLevel = speakerLevel.clamp(0.0, 1.0).toDouble(); + final haloLevel = active ? 0.08 + normalizedSpeakerLevel * 0.92 : 0.0; + final haloController = useAnimationController( + initialValue: reducedMotion ? haloLevel : 0, + ); + final animatedHaloLevel = useAnimation(haloController); + useEffect(() { + haloController.stop(); + if (reducedMotion) { + haloController.value = haloLevel; + return null; + } + + final isRising = haloLevel > haloController.value; + haloController.animateTo( + haloLevel, + duration: Duration(milliseconds: isRising ? 140 : 220), + curve: isRising ? Curves.easeOutCubic : Curves.easeInOutCubic, + ); + return null; + }, [haloController, haloLevel, reducedMotion]); + final originKey = useMemoized( + () => GlobalKey(debugLabel: 'huddle-reaction-origin-$pubkey'), + [pubkey], + ); + final originRegistry = ref.read(_huddleAvatarOriginRegistryProvider); + useEffect(() { + originRegistry.register(pubkey, originKey); + return () => originRegistry.unregister(pubkey, originKey); + }, [originRegistry, pubkey, originKey]); final showsLabel = useState(false); final profileName = profile?.displayName?.trim(); final directoryName = fallbackLabel?.trim(); @@ -29,7 +67,7 @@ class _HuddleCallAvatar extends HookWidget { void toggleLabel() => showsLabel.value = !showsLabel.value; return SizedBox( - width: _huddleAvatarFrameSize, + width: frameSize, child: Semantics( label: active ? '$label, speaking' : label, hint: showsLabel.value @@ -47,49 +85,56 @@ class _HuddleCallAvatar extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - TweenAnimationBuilder( + SizedBox.square( key: ValueKey('huddle-speaking-ring-$pubkey'), - duration: reducedMotion - ? Duration.zero - : const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - tween: Tween(end: active ? 1 : 0), - builder: (context, value, child) => SizedBox.square( - dimension: _huddleAvatarFrameSize, + dimension: frameSize, + child: SizedBox.square( + key: originKey, + dimension: frameSize, child: Stack( alignment: Alignment.center, + clipBehavior: Clip.none, children: [ - Opacity( - opacity: value, + AnimatedOpacity( + key: ValueKey('huddle-speaking-halo-opacity-$pubkey'), + duration: reducedMotion + ? Duration.zero + : Duration(milliseconds: active ? 140 : 220), + curve: active + ? Curves.easeOutCubic + : Curves.easeInOutCubic, + opacity: active ? 1 : 0, child: Transform.scale( - scale: 1 + value * 0.08, + key: ValueKey('huddle-speaking-halo-scale-$pubkey'), + scale: reducedMotion + ? (active ? 1.15 : 1) + : 1 + animatedHaloLevel * 1.55, child: Container( - width: _huddleSpeakingRingSize, - height: _huddleSpeakingRingSize, + key: ValueKey('huddle-speaking-halo-$pubkey'), + width: speakingRingSize, + height: speakingRingSize, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all( - color: context.colors.primary, - width: 4, + color: context.colors.primary.withValues( + alpha: 0.07, ), ), ), ), ), - child!, + AvatarImage( + imageUrl: profile?.avatarUrl, + radius: avatarRadius, + backgroundColor: context.colors.primaryContainer, + fallback: Icon( + LucideIcons.userRound, + size: fallbackIconSize, + color: context.colors.onPrimaryContainer, + ), + ), ], ), ), - child: AvatarImage( - imageUrl: profile?.avatarUrl, - radius: _huddleAvatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Icon( - LucideIcons.userRound, - size: 44, - color: context.colors.onPrimaryContainer, - ), - ), ), AnimatedSwitcher( key: ValueKey('huddle-participant-label-reveal-$pubkey'), @@ -118,7 +163,7 @@ class _HuddleCallAvatar extends HookWidget { key: ValueKey('huddle-participant-label-$pubkey'), padding: const EdgeInsets.only(top: Grid.half), child: SizedBox( - width: _huddleAvatarFrameSize, + width: frameSize, child: Text( label, maxLines: 1, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart index b6f08a660..5a9b83878 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart @@ -6,16 +6,19 @@ class _HuddleCallControls extends StatelessWidget { required this.isSpeakerEnabled, required this.onToggleMute, required this.onToggleSpeaker, + required this.onReact, }); final bool isMuted; final bool isSpeakerEnabled; final VoidCallback onToggleMute; final VoidCallback onToggleSpeaker; + final VoidCallback onReact; @override Widget build(BuildContext context) { return Padding( + key: const ValueKey('huddle-call-controls'), padding: const EdgeInsets.only(top: Grid.xxs), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -30,7 +33,7 @@ class _HuddleCallControls extends StatelessWidget { backgroundColor: isSpeakerEnabled ? context.colors.primary : context.colors.surfaceContainerHighest, - dimension: 72, + dimension: 80, toggled: isSpeakerEnabled, onPressed: onToggleSpeaker, ), @@ -45,10 +48,20 @@ class _HuddleCallControls extends StatelessWidget { backgroundColor: isMuted ? context.colors.surfaceContainerHighest : context.colors.primary, - dimension: 72, + dimension: 80, toggled: isMuted, onPressed: onToggleMute, ), + const SizedBox(width: Grid.sm), + _HuddleRoundControl( + key: const ValueKey('huddle-emoji-reactions'), + tooltip: 'Emoji reactions', + icon: LucideIcons.smilePlus, + foregroundColor: context.colors.onSurface, + backgroundColor: context.colors.surfaceContainerHighest, + dimension: 80, + onPressed: onReact, + ), ], ), ); diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart index 3cb73da08..c7ef3dda5 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -9,6 +9,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.remotePubkeys, required this.localPubkey, required this.activeSpeakerPubkeys, + required this.speakerLevels, required this.retryTooltip, required this.retryIcon, required this.onRetry, @@ -21,6 +22,7 @@ class _HuddleCallParticipants extends StatelessWidget { final List remotePubkeys; final String? localPubkey; final Set activeSpeakerPubkeys; + final Map speakerLevels; final String retryTooltip; final IconData retryIcon; final VoidCallback onRetry; @@ -60,8 +62,16 @@ class _HuddleCallParticipants extends StatelessWidget { ); } + if (!connected) { + return const Center(child: _HuddleLoadingBee()); + } + final reducedMotion = MediaQuery.disableAnimationsOf(context); - final hasRemoteParticipants = connected && remotePubkeys.isNotEmpty; + final hasRemoteParticipants = remotePubkeys.isNotEmpty; + final hasDenseRemoteRoster = + remotePubkeys.length > _huddleDenseParticipantThreshold; + final remoteHeightFactor = hasDenseRemoteRoster ? 0.58 : 0.5; + final localHeightFactor = hasDenseRemoteRoster ? 0.42 : 0.5; final movementDuration = reducedMotion ? Duration.zero : const Duration(milliseconds: 260); @@ -83,7 +93,7 @@ class _HuddleCallParticipants extends StatelessWidget { end: hasRemoteParticipants ? 1 : 0, ), builder: (context, value, child) => FractionallySizedBox( - heightFactor: 0.5, + heightFactor: localHeightFactor, alignment: Alignment.lerp( Alignment.center, Alignment.bottomCenter, @@ -106,53 +116,70 @@ class _HuddleCallParticipants extends StatelessWidget { active: localPubkey != null && activeSpeakerPubkeys.contains(localPubkey), + speakerLevel: localPubkey == null + ? 0 + : speakerLevels[localPubkey] ?? 0, isSelf: true, ), ), ), Positioned.fill( child: FractionallySizedBox( - heightFactor: 0.5, + key: const ValueKey('huddle-remote-participant-region'), + heightFactor: remoteHeightFactor, alignment: Alignment.topCenter, child: Align( key: const ValueKey('huddle-remote-participant-group'), alignment: const Alignment(0, 0.35), - child: connected - ? SingleChildScrollView( - key: const ValueKey('huddle-remote-participants'), - padding: const EdgeInsets.symmetric(vertical: Grid.xs), - child: Wrap( - alignment: WrapAlignment.center, - spacing: Grid.md, - runSpacing: Grid.xs, - children: [ - for (final pubkey in remotePubkeys) - SizedBox( - key: ValueKey('huddle-participant-entry-$pubkey'), - width: _huddleAvatarFrameSize, - child: TweenAnimationBuilder( - duration: entryDuration, - curve: Curves.easeOutCubic, - tween: Tween(begin: 0, end: 1), - builder: (context, value, child) => Opacity( - opacity: value, - child: Transform.scale( - scale: 0.95 + value * 0.05, - child: child, - ), - ), - child: _HuddleCallAvatar( - pubkey: pubkey, - profile: profiles[pubkey], - fallbackLabel: fallbackLabels[pubkey], - active: activeSpeakerPubkeys.contains(pubkey), + child: LayoutBuilder( + builder: (context, constraints) { + final layout = _HuddleParticipantLayout.fit( + availableSize: constraints.biggest, + participantCount: remotePubkeys.length, + dense: hasDenseRemoteRoster, + ); + return TweenAnimationBuilder( + key: const ValueKey('huddle-remote-participants'), + duration: movementDuration, + curve: Curves.easeInOutCubic, + tween: Tween(end: layout.frameSize), + builder: (context, frameSize, _) => Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + spacing: layout.spacing, + runSpacing: layout.runSpacing, + children: [ + for (final pubkey in remotePubkeys) + SizedBox( + key: ValueKey('huddle-participant-entry-$pubkey'), + width: frameSize, + height: frameSize + _huddleParticipantLabelSpace, + child: TweenAnimationBuilder( + duration: entryDuration, + curve: Curves.easeOutCubic, + tween: Tween(begin: 0, end: 1), + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.scale( + scale: 0.95 + value * 0.05, + child: child, ), ), + child: _HuddleCallAvatar( + pubkey: pubkey, + profile: profiles[pubkey], + fallbackLabel: fallbackLabels[pubkey], + active: activeSpeakerPubkeys.contains(pubkey), + speakerLevel: speakerLevels[pubkey] ?? 0, + frameSize: frameSize, + ), ), - ], - ), - ) - : const BuzzLoadingIndicator(size: 40), + ), + ], + ), + ); + }, + ), ), ), ), @@ -160,3 +187,98 @@ class _HuddleCallParticipants extends StatelessWidget { ); } } + +class _HuddleLoadingBee extends HookWidget { + const _HuddleLoadingBee(); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final flapController = useAnimationController( + duration: const Duration(milliseconds: 480), + ); + final flapProgress = useAnimation(flapController); + useEffect(() { + if (reducedMotion) { + flapController + ..stop() + ..reset(); + } else { + flapController.repeat(); + } + return null; + }, [flapController, reducedMotion]); + final flapAmount = reducedMotion + ? 0.0 + : 0.5 - (0.5 * cos(flapProgress * 4 * pi)); + + return Semantics( + label: 'Joining Huddle', + liveRegion: true, + child: ExcludeSemantics( + child: FlappingBee( + key: const ValueKey('huddle-loading-bee'), + width: 60, + color: context.colors.primary, + flapAmount: flapAmount, + ), + ), + ); + } +} + +class _HuddleParticipantLayout { + const _HuddleParticipantLayout({ + required this.frameSize, + required this.spacing, + required this.runSpacing, + }); + + final double frameSize; + final double spacing; + final double runSpacing; + + static _HuddleParticipantLayout fit({ + required Size availableSize, + required int participantCount, + required bool dense, + }) { + const spacing = Grid.xs; + const runSpacing = Grid.xxs; + final maximumFrameSize = dense + ? _huddleDenseAvatarFrameSize + : _huddleAvatarFrameSize; + if (participantCount <= 0 || availableSize.isEmpty) { + return _HuddleParticipantLayout( + frameSize: maximumFrameSize, + spacing: spacing, + runSpacing: runSpacing, + ); + } + + var bestFrameSize = _huddleMinimumAvatarFrameSize; + for (var columns = 1; columns <= participantCount; columns++) { + final rows = (participantCount + columns - 1) ~/ columns; + final widthForAvatars = availableSize.width - spacing * (columns - 1); + final heightForAvatars = + availableSize.height - + runSpacing * (rows - 1) - + _huddleParticipantLabelSpace * rows; + if (widthForAvatars <= 0 || heightForAvatars <= 0) continue; + + final widthBound = widthForAvatars / columns; + final heightBound = heightForAvatars / rows; + final candidate = min(maximumFrameSize, min(widthBound, heightBound)); + if (candidate > bestFrameSize) bestFrameSize = candidate; + } + + return _HuddleParticipantLayout( + frameSize: bestFrameSize.clamp( + _huddleMinimumAvatarFrameSize, + maximumFrameSize, + ), + spacing: spacing, + runSpacing: runSpacing, + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart index 9569ad40e..8bffac15c 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart @@ -7,7 +7,7 @@ const _mobileHuddleDrawerCurve = Cubic(0.32, 0.72, 0, 1); /// Lifts the mobile app above a persistent Huddle control drawer when the /// full-screen call has been minimized. -class MobileHuddleShell extends ConsumerWidget { +class MobileHuddleShell extends HookConsumerWidget { const MobileHuddleShell({ super.key, required this.navigatorKey, @@ -21,6 +21,15 @@ class MobileHuddleShell extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final presentation = ref.watch(mobileHuddlePresentationProvider); final session = ref.watch(huddleSessionProvider); + final relayStatus = ref.watch(relaySessionProvider).status; + _useIncomingHuddleReactions( + context: context, + ref: ref, + inSession: session.isInSession, + channelId: session.ephemeralChannelId, + currentPubkey: session.currentPubkey, + relayStatus: relayStatus, + ); final drawerOpen = presentation == MobileHuddlePresentation.drawer && session.isInSession; final reducedMotion = MediaQuery.disableAnimationsOf(context); @@ -76,6 +85,7 @@ class MobileHuddleShell extends ConsumerWidget { ), enabled: drawerOpen, color: context.colors.surface, + backdropColor: surface, corners: ConcentricSurfaceCorners.bottom, padding: EdgeInsets.zero, providesSheetSurface: false, @@ -188,76 +198,80 @@ class _MobileHuddleDrawer extends ConsumerWidget { child: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - child: Row( - key: const ValueKey('huddle-drawer-controls'), - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - key: const ValueKey('huddle-drawer-primary-controls'), - mainAxisSize: MainAxisSize.min, - children: [ - _HuddleRoundControl( - key: const ValueKey('huddle-drawer-expand'), - tooltip: 'Open Huddle', - showTooltip: false, - icon: LucideIcons.chevronUp, - foregroundColor: foreground, - backgroundColor: controlSurface, - onPressed: restoreFullScreen, - ), - const SizedBox(width: Grid.twelve), - _HuddleRoundControl( - key: const ValueKey('huddle-drawer-speaker-toggle'), - tooltip: session.isSpeakerEnabled - ? 'Use earpiece' - : 'Use speaker', - showTooltip: false, - icon: LucideIcons.volume2, - foregroundColor: session.isSpeakerEnabled - ? drawerSurface - : foreground, - backgroundColor: session.isSpeakerEnabled - ? foreground - : controlSurface, - toggled: session.isSpeakerEnabled, - onPressed: () => unawaited( - sessionController.setSpeakerEnabled( - !session.isSpeakerEnabled, + child: Transform.translate( + key: const ValueKey('huddle-drawer-control-offset'), + offset: const Offset(0, -8), + child: Row( + key: const ValueKey('huddle-drawer-controls'), + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + key: const ValueKey('huddle-drawer-primary-controls'), + mainAxisSize: MainAxisSize.min, + children: [ + _HuddleRoundControl( + key: const ValueKey('huddle-drawer-expand'), + tooltip: 'Open Huddle', + showTooltip: false, + icon: LucideIcons.chevronUp, + foregroundColor: foreground, + backgroundColor: controlSurface, + onPressed: restoreFullScreen, + ), + const SizedBox(width: Grid.twelve), + _HuddleRoundControl( + key: const ValueKey('huddle-drawer-speaker-toggle'), + tooltip: session.isSpeakerEnabled + ? 'Use earpiece' + : 'Use speaker', + showTooltip: false, + icon: LucideIcons.volume2, + foregroundColor: session.isSpeakerEnabled + ? drawerSurface + : foreground, + backgroundColor: session.isSpeakerEnabled + ? foreground + : controlSurface, + toggled: session.isSpeakerEnabled, + onPressed: () => unawaited( + sessionController.setSpeakerEnabled( + !session.isSpeakerEnabled, + ), ), ), - ), - const SizedBox(width: Grid.twelve), - _HuddleRoundControl( - key: const ValueKey('huddle-drawer-mute-toggle'), - tooltip: session.isMuted ? 'Unmute' : 'Mute', - showTooltip: false, - icon: session.isMuted - ? LucideIcons.micOff - : LucideIcons.mic, - foregroundColor: session.isMuted - ? foreground - : drawerSurface, - backgroundColor: session.isMuted - ? controlSurface - : foreground, - toggled: session.isMuted, - onPressed: () => unawaited( - sessionController.setMuted(!session.isMuted), + const SizedBox(width: Grid.twelve), + _HuddleRoundControl( + key: const ValueKey('huddle-drawer-mute-toggle'), + tooltip: session.isMuted ? 'Unmute' : 'Mute', + showTooltip: false, + icon: session.isMuted + ? LucideIcons.micOff + : LucideIcons.mic, + foregroundColor: session.isMuted + ? foreground + : drawerSurface, + backgroundColor: session.isMuted + ? controlSurface + : foreground, + toggled: session.isMuted, + onPressed: () => unawaited( + sessionController.setMuted(!session.isMuted), + ), ), - ), - ], - ), - _HuddleRoundControl( - key: const ValueKey('huddle-drawer-leave'), - tooltip: 'Leave Huddle', - showTooltip: false, - icon: LucideIcons.phoneOff, - foregroundColor: context.colors.error, - backgroundColor: controlSurface, - onPressed: leave, - ), - ], + ], + ), + _HuddleRoundControl( + key: const ValueKey('huddle-drawer-leave'), + tooltip: 'Leave Huddle', + showTooltip: false, + icon: LucideIcons.phoneOff, + foregroundColor: context.colors.error, + backgroundColor: controlSurface, + onPressed: leave, + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_reactions.dart b/mobile/lib/features/channels/channel_detail_page/huddle_reactions.dart new file mode 100644 index 000000000..e00f96a81 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/huddle_reactions.dart @@ -0,0 +1,117 @@ +part of '../channel_detail_page.dart'; + +final _huddleAvatarOriginRegistryProvider = Provider( + (_) => _HuddleAvatarOriginRegistry(), +); + +class _HuddleAvatarOriginRegistry { + final Map _keys = {}; + + void register(String pubkey, GlobalKey key) { + _keys[pubkey.toLowerCase()] = key; + } + + void unregister(String pubkey, GlobalKey key) { + final normalized = pubkey.toLowerCase(); + if (identical(_keys[normalized], key)) _keys.remove(normalized); + } + + Offset? originFor(String? pubkey) { + if (pubkey == null) return null; + final context = _keys[pubkey.toLowerCase()]?.currentContext; + final box = context?.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return box.localToGlobal(box.size.center(Offset.zero)); + } +} + +void _burstHuddleReaction({ + required WidgetRef ref, + required BuildContext fallbackContext, + required String emoji, + required String? senderPubkey, +}) { + if (MediaQuery.maybeDisableAnimationsOf(fallbackContext) ?? false) return; + final origin = ref + .read(_huddleAvatarOriginRegistryProvider) + .originFor(senderPubkey); + if (origin == null) { + burstEmojiFromContext(ref, fallbackContext, emoji, requirePositive: false); + return; + } + ref.read(emojiBurstControllerProvider).burst(emoji, origin); +} + +void _useIncomingHuddleReactions({ + required BuildContext context, + required WidgetRef ref, + required bool inSession, + required String? channelId, + required String? currentPubkey, + required SessionStatus relayStatus, +}) { + useEffect(() { + if (!inSession || + channelId == null || + relayStatus != SessionStatus.connected) { + return null; + } + + final relaySession = ref.read(relaySessionProvider.notifier); + final seenEventIds = {}; + var disposed = false; + VoidCallback? unsubscribe; + Future.microtask(() async { + try { + final cleanup = await relaySession.subscribe( + NostrFilter( + kinds: const [EventKind.huddleReaction], + tags: { + '#h': [channelId], + }, + limit: 1000, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), + (event) { + if (disposed || !seenEventIds.add(event.id)) return; + if (event.pubkey.toLowerCase() == currentPubkey?.toLowerCase()) { + return; + } + final emoji = _huddleReactionEmoji(event); + if (emoji == null || !context.mounted) return; + _burstHuddleReaction( + ref: ref, + fallbackContext: context, + emoji: emoji, + senderPubkey: event.pubkey, + ); + }, + ); + if (disposed) { + cleanup(); + } else { + unsubscribe = cleanup; + } + } catch (error) { + if (!disposed) { + debugPrint('[MobileHuddle] reaction subscription failed: $error'); + } + } + }); + + return () { + disposed = true; + unsubscribe?.call(); + }; + }, [inSession, channelId, currentPubkey, relayStatus]); +} + +String? _huddleReactionEmoji(NostrEvent event) { + if (event.kind != EventKind.huddleReaction) return null; + for (final tag in event.tags) { + if (tag.length < 2 || tag.first != 'reaction') continue; + final emoji = tag[1].trim(); + if (emoji.isNotEmpty) return emoji; + } + final emoji = event.content.trim(); + return emoji.isEmpty ? null : emoji; +} diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index a3c8ff2e5..44c630048 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -4,6 +4,10 @@ const _huddleLifetime = Duration(hours: 1); const _huddleAvatarRadius = 52.0; const _huddleAvatarFrameSize = 128.0; const _huddleSpeakingRingSize = 112.0; +const _huddleDenseAvatarFrameSize = 104.0; +const _huddleMinimumAvatarFrameSize = 48.0; +const _huddleParticipantLabelSpace = 28.0; +const _huddleDenseParticipantThreshold = 6; final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose .family<_HuddleParticipantProfileUpdates, int, String>( @@ -554,6 +558,10 @@ class _MobileHuddleCallPage extends ConsumerWidget { ); final profiles = ref.watch(userCacheProvider); final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider); + final reactionSenderName = _huddleReactionSenderName( + localPubkey: localPubkey, + profile: localPubkey == null ? null : profiles[localPubkey], + ); return PopScope( onPopInvokedWithResult: (didPop, _) { @@ -605,6 +613,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { remotePubkeys: remotePubkeys, localPubkey: localPubkey, activeSpeakerPubkeys: session.activeSpeakerPubkeys, + speakerLevels: session.speakerLevels, retryTooltip: unavailable ? 'Start a new Huddle' : 'Try again', @@ -640,6 +649,35 @@ class _MobileHuddleCallPage extends ConsumerWidget { !session.isSpeakerEnabled, ), ), + onReact: () => showEmojiPicker( + context: context, + onSelect: (emoji) { + ref.read(recentEmojiProvider.notifier).record(emoji); + _burstHuddleReaction( + ref: ref, + fallbackContext: context, + emoji: emoji, + senderPubkey: localPubkey, + ); + unawaited( + ref + .read(channelActionsProvider) + .sendHuddleReaction( + channelId: invite.ephemeralChannelId, + emoji: emoji, + senderName: reactionSenderName, + ) + .catchError((Object error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Reaction failed'), + ), + ); + }), + ); + }, + ), ) else const SizedBox(height: Grid.xxl), @@ -652,6 +690,18 @@ class _MobileHuddleCallPage extends ConsumerWidget { } } +String _huddleReactionSenderName({ + required String? localPubkey, + required UserProfile? profile, +}) { + final displayName = profile?.displayName?.trim(); + if (displayName?.isNotEmpty == true) return displayName!; + if (localPubkey?.isNotEmpty == true) { + return 'Participant ${shortPubkey(localPubkey!)}'; + } + return 'Someone'; +} + class _HuddleCallHeader extends StatelessWidget { const _HuddleCallHeader({ required this.isLeaving, diff --git a/mobile/lib/features/channels/channel_huddle_actions.dart b/mobile/lib/features/channels/channel_huddle_actions.dart index 6cf66f506..537bf4439 100644 --- a/mobile/lib/features/channels/channel_huddle_actions.dart +++ b/mobile/lib/features/channels/channel_huddle_actions.dart @@ -1,5 +1,13 @@ part of 'channel_management_provider.dart'; +const _huddleReactionNameMax = 48; + +String _clampHuddleReactionName(String name) { + final trimmed = name.trim(); + if (trimmed.length <= _huddleReactionNameMax) return trimmed; + return '${trimmed.substring(0, _huddleReactionNameMax - 1).trimRight()}…'; +} + /// Canonical Huddle lifecycle operations built on the shared channel actions. extension HuddleChannelActions on ChannelActions { /// Creates the private, one-hour stream used only by Huddle media. @@ -54,4 +62,30 @@ extension HuddleChannelActions on ChannelActions { ], ); } + + /// Sends an ephemeral emoji burst to the active Huddle backing channel. + Future sendHuddleReaction({ + required String channelId, + required String emoji, + required String senderName, + }) async { + final normalizedEmoji = emoji.trim(); + if (normalizedEmoji.isEmpty) return; + final shortcode = normalizeShortcode(normalizedEmoji); + final emojiUrl = reactionEmojiUrl( + normalizedEmoji, + _ref.read(customEmojiListProvider), + ); + await _signedEventRelay.submit( + kind: EventKind.huddleReaction, + content: normalizedEmoji, + tags: [ + ['h', channelId], + ['reaction', normalizedEmoji], + ['sender_name', _clampHuddleReactionName(senderName)], + if (shortcode != null && emojiUrl != null) + ['emoji', shortcode, emojiUrl], + ], + ); + } } diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index e815cf41f..eacefa269 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -50,19 +50,23 @@ final mobileHuddlePresentationProvider = /// ending the Huddle on transient relay failure. final huddleHumanCountProvider = Provider((ref) { return (channelId) async { - final membersProvider = channelMembersProvider(channelId); - final cachedMembers = ref.read(membersProvider).value; - if (cachedMembers != null) { - return cachedMembers.where((member) => !member.isBot).length; - } if (ref.read(relaySessionProvider).status != SessionStatus.connected) { throw StateError('Relay is unavailable for Huddle member lookup.'); } - final members = await ref.read(membersProvider.future); + // Desktop always queries the relay's current kind:39002 snapshot here. + // The UI-facing provider intentionally preserves cached members through + // reconnects, but that cache can lag a leave event and must not decide + // whether the room ends for everyone. + final events = await ref + .read(relaySessionProvider.notifier) + .fetchHistory(NostrFilters.channelMembers(channelId)); if (ref.read(relaySessionProvider).status != SessionStatus.connected) { throw StateError('Relay disconnected during Huddle member lookup.'); } - return members.where((member) => !member.isBot).length; + if (events.isEmpty) return 0; + return membersFromEvent( + events.first, + ).where((member) => member.role != 'bot').length; }; }); diff --git a/mobile/lib/shared/emoji/emoji_burst.dart b/mobile/lib/shared/emoji/emoji_burst.dart index 7717ea374..b0ade73fa 100644 --- a/mobile/lib/shared/emoji/emoji_burst.dart +++ b/mobile/lib/shared/emoji/emoji_burst.dart @@ -155,6 +155,7 @@ class _Particle { class EmojiBurstController extends ChangeNotifier { final List<_Particle> _particles = []; final Random _random; + Offset? _lastBurstOrigin; /// Set when a spawn arrives so the overlay knows to start its ticker. VoidCallback? onSpawn; @@ -163,12 +164,16 @@ class EmojiBurstController extends ChangeNotifier { bool get hasParticles => _particles.isNotEmpty; + @visibleForTesting + Offset? get debugLastBurstOrigin => _lastBurstOrigin; + /// Spawn a burst of [emoji] centred on [origin] (global coordinates). /// Mirrors desktop's `spawnPickerEmojiBurst`. void burst(String emoji, Offset origin) { final trimmed = emoji.trim(); if (trimmed.isEmpty) return; if (_particles.length + _particlesPerBurst > _maxActiveParticles) return; + _lastBurstOrigin = origin; for (var i = 0; i < _particlesPerBurst; i += 1) { final horizontalDrift = (_random.nextDouble() - 0.5) * 4.4; @@ -211,6 +216,7 @@ class EmojiBurstController extends ChangeNotifier { } void clear() { + _lastBurstOrigin = null; if (_particles.isEmpty) return; _particles.clear(); notifyListeners(); diff --git a/mobile/lib/shared/huddle/huddle_session.dart b/mobile/lib/shared/huddle/huddle_session.dart index 839dba7b4..b49af484c 100644 --- a/mobile/lib/shared/huddle/huddle_session.dart +++ b/mobile/lib/shared/huddle/huddle_session.dart @@ -39,6 +39,7 @@ final class HuddleSessionState { final int participantCount; final List participantPubkeys; final Set activeSpeakerPubkeys; + final Map speakerLevels; final int reconnectAttempt; final int receivedFrameCount; final int sentFrameCount; @@ -58,6 +59,7 @@ final class HuddleSessionState { this.participantCount = 0, this.participantPubkeys = const [], this.activeSpeakerPubkeys = const {}, + this.speakerLevels = const {}, this.reconnectAttempt = 0, this.receivedFrameCount = 0, this.sentFrameCount = 0, @@ -94,6 +96,7 @@ final class HuddleSessionState { int? participantCount, List? participantPubkeys, Set? activeSpeakerPubkeys, + Map? speakerLevels, int? reconnectAttempt, int? receivedFrameCount, int? sentFrameCount, @@ -120,6 +123,7 @@ final class HuddleSessionState { participantCount: participantCount ?? this.participantCount, participantPubkeys: participantPubkeys ?? this.participantPubkeys, activeSpeakerPubkeys: activeSpeakerPubkeys ?? this.activeSpeakerPubkeys, + speakerLevels: speakerLevels ?? this.speakerLevels, reconnectAttempt: reconnectAttempt ?? this.reconnectAttempt, receivedFrameCount: receivedFrameCount ?? this.receivedFrameCount, sentFrameCount: sentFrameCount ?? this.sentFrameCount, @@ -173,6 +177,8 @@ final class HuddleSessionNotifier extends Notifier { var _sentFrames = 0; Future _playbackTail = Future.value(); final Map _speakerTimers = {}; + final Map _pendingSpeakerLevels = {}; + Timer? _speakerLevelFlushTimer; Timer? _reconnectTimer; var _reconnectAttempt = 0; var _reconnectInFlight = false; @@ -366,7 +372,11 @@ final class HuddleSessionNotifier extends Notifier { if (!frame.header.isDtx && frame.header.levelDbov >= -55) { final currentPubkey = state.currentPubkey; if (currentPubkey != null) { - _recordSpeakerPubkey(currentPubkey, generation); + _recordSpeakerPubkey( + currentPubkey, + frame.header.levelDbov, + generation, + ); } } _sentFrames += 1; @@ -447,32 +457,64 @@ final class HuddleSessionNotifier extends Notifier { if (frame.header.isDtx || frame.header.levelDbov < -55) return; final pubkey = transport.state.peers[frame.peerIndex]?.pubkey.toLowerCase(); if (pubkey == null) return; - _recordSpeakerPubkey(pubkey, generation); + _recordSpeakerPubkey(pubkey, frame.header.levelDbov, generation); } - void _recordSpeakerPubkey(String pubkey, int generation) { - final speakers = {...state.activeSpeakerPubkeys, pubkey}; - if (!state.activeSpeakerPubkeys.contains(pubkey)) { - state = state.copyWith(activeSpeakerPubkeys: Set.unmodifiable(speakers)); + void _recordSpeakerPubkey(String pubkey, int levelDbov, int generation) { + final normalized = pubkey.toLowerCase(); + final wasActive = state.activeSpeakerPubkeys.contains(normalized); + final level = _speakerLevelFromDbov(levelDbov); + if (!wasActive) { + final speakers = {...state.activeSpeakerPubkeys, normalized}; + final levels = {...state.speakerLevels, normalized: level}; + state = state.copyWith( + activeSpeakerPubkeys: Set.unmodifiable(speakers), + speakerLevels: Map.unmodifiable(levels), + ); + } else { + _pendingSpeakerLevels[normalized] = level; + _scheduleSpeakerLevelFlush(generation); } - _speakerTimers.remove(pubkey)?.cancel(); - _speakerTimers[pubkey] = Timer(const Duration(milliseconds: 600), () { + _speakerTimers.remove(normalized)?.cancel(); + _speakerTimers[normalized] = Timer(const Duration(milliseconds: 600), () { if (!_isCurrent(generation)) return; - _clearSpeaker(pubkey); + _clearSpeaker(normalized); }); } void _clearSpeaker(String pubkey) { final normalized = pubkey.toLowerCase(); _speakerTimers.remove(normalized)?.cancel(); - if (!state.activeSpeakerPubkeys.contains(normalized)) return; + _pendingSpeakerLevels.remove(normalized); + if (!state.activeSpeakerPubkeys.contains(normalized) && + !state.speakerLevels.containsKey(normalized)) { + return; + } + final levels = Map.from(state.speakerLevels) + ..remove(normalized); state = state.copyWith( activeSpeakerPubkeys: Set.unmodifiable( state.activeSpeakerPubkeys.where((value) => value != normalized), ), + speakerLevels: Map.unmodifiable(levels), ); } + void _scheduleSpeakerLevelFlush(int generation) { + if (_speakerLevelFlushTimer != null) return; + _speakerLevelFlushTimer = Timer(const Duration(milliseconds: 50), () { + _speakerLevelFlushTimer = null; + if (!_isCurrent(generation)) { + _pendingSpeakerLevels.clear(); + return; + } + if (_pendingSpeakerLevels.isEmpty) return; + final levels = {...state.speakerLevels, ..._pendingSpeakerLevels}; + _pendingSpeakerLevels.clear(); + state = state.copyWith(speakerLevels: Map.unmodifiable(levels)); + }); + } + void _scheduleReconnect( HuddleTransportClient transport, int generation, @@ -551,6 +593,9 @@ final class HuddleSessionNotifier extends Notifier { timer.cancel(); } _speakerTimers.clear(); + _speakerLevelFlushTimer?.cancel(); + _speakerLevelFlushTimer = null; + _pendingSpeakerLevels.clear(); final transport = _transport; final media = _media; _transport = null; @@ -605,3 +650,6 @@ final class HuddleSessionNotifier extends Notifier { _ => 'Unable to join the Huddle.', }; } + +double _speakerLevelFromDbov(int levelDbov) => + ((levelDbov + 55) / 55).clamp(0.0, 1.0).toDouble(); diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 7a6abd259..38e27b848 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -60,7 +60,10 @@ class AppTheme { warning: const Color(0xFFEED49F), // Macchiato Yellow accent: scheme.tertiary, huddleDrawerSurface: scheme.primaryContainer, - huddleControlSurface: scheme.secondaryContainer, + huddleControlSurface: Color.alphaBlend( + scheme.onPrimaryContainer.withValues(alpha: 0.18), + scheme.primaryContainer, + ), onHuddleDrawer: scheme.onPrimaryContainer, topSectionGradient: topSectionGradient, ); diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart index ec148ce75..2a8878acd 100644 --- a/mobile/lib/shared/widgets/concentric_sheet_surface.dart +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -15,6 +17,7 @@ class ConcentricSheetSurface extends HookWidget { required this.child, required this.enabled, this.color, + this.backdropColor, this.corners = ConcentricSurfaceCorners.all, this.padding = const EdgeInsets.only( left: Grid.xxs, @@ -28,6 +31,7 @@ class ConcentricSheetSurface extends HookWidget { final Widget child; final bool enabled; final Color? color; + final Color? backdropColor; final ConcentricSurfaceCorners corners; final EdgeInsetsGeometry padding; final bool providesSheetSurface; @@ -61,6 +65,23 @@ class ConcentricSheetSurface extends HookWidget { } } + Future _updateNativeSurfaceColors({ + required MethodChannel channel, + required Color surfaceColor, + required Color? backdropColor, + }) async { + try { + await channel.invokeMethod('updateColors', { + 'color': surfaceColor.toARGB32(), + 'backdropColor': backdropColor?.toARGB32(), + }); + } on MissingPluginException { + // The platform view may have been disposed while its theme was changing. + } on PlatformException { + // The native surface is optional; retain its last successfully sent color. + } + } + @override Widget build(BuildContext context) { final shouldCheckNativeSurface = @@ -72,12 +93,38 @@ class ConcentricSheetSurface extends HookWidget { [shouldCheckNativeSurface], ); final nativeSurfaceSupported = useFuture(supportFuture).data ?? false; + final surfaceColor = color ?? context.colors.surface; + final nativeSurfaceChannel = useState(null); + useEffect( + () { + final channel = nativeSurfaceChannel.value; + if (!shouldCheckNativeSurface || + !nativeSurfaceSupported || + channel == null) { + return null; + } + unawaited( + _updateNativeSurfaceColors( + channel: channel, + surfaceColor: surfaceColor, + backdropColor: backdropColor, + ), + ); + return null; + }, + [ + shouldCheckNativeSurface, + nativeSurfaceSupported, + nativeSurfaceChannel.value, + surfaceColor, + backdropColor, + ], + ); if (!shouldCheckNativeSurface) { return _ConcentricSheetSurfaceScope(providesSurface: false, child: child); } - final surfaceColor = color ?? context.colors.surface; final fallbackBorderRadius = _borderRadius(Radii.dialog); return Padding( @@ -90,8 +137,15 @@ class ConcentricSheetSurface extends HookWidget { child: UiKitView( viewType: 'buzz/concentric_sheet_surface', hitTestBehavior: PlatformViewHitTestBehavior.transparent, + onPlatformViewCreated: (viewId) { + nativeSurfaceChannel.value = MethodChannel( + 'buzz/concentric_sheet_surface/$viewId', + ); + }, creationParams: { 'color': surfaceColor.toARGB32(), + if (backdropColor case final color?) + 'backdropColor': color.toARGB32(), 'minimumRadius': Radii.dialog, 'corners': corners.name, }, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2f0dd7c01..b39c5202a 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -35,12 +35,14 @@ import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/shared/emoji/emoji_burst.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/huddle/huddle.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/frosted_scaffold.dart'; +import 'package:buzz/shared/widgets/flapping_bee.dart'; import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; @@ -3416,6 +3418,65 @@ void main() { expect(relaySession.publishedKinds, isEmpty); }); + testWidgets('shows the flapping bee instead of an avatar while joining', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final connectGate = Completer(); + final transport = _HuddleTestTransport(connectGate: connectGate.future); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'loading-call-layout', + kind: EventKind.huddleStarted, + pubkey: 'desktop', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop'), + 'self': UserProfile(pubkey: 'self'), + }, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(); + + final loadingBee = find.byKey(const ValueKey('huddle-loading-bee')); + expect(loadingBee, findsOneWidget); + expect(find.byType(FlappingBee), findsOneWidget); + expect(tester.widget(loadingBee).width, 60); + expect(find.bySemanticsLabel('Joining Huddle'), findsOneWidget); + expect( + find.byKey(const ValueKey('huddle-participant-avatar-self')), + findsNothing, + ); + final initialFlap = tester.widget(loadingBee).flapAmount; + await tester.pump(const Duration(milliseconds: 120)); + expect( + tester.widget(loadingBee).flapAmount, + isNot(initialFlap), + ); + + connectGate.complete(); + await tester.pumpAndSettle(); + + expect(loadingBee, findsNothing); + expect( + find.byKey(const ValueKey('huddle-participant-avatar-self')), + findsOneWidget, + ); + }); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -3475,11 +3536,40 @@ void main() { ); expect( tester.getSize(find.byKey(const ValueKey('huddle-speaker-toggle'))), - const Size.square(72), + const Size.square(80), ); expect( tester.getSize(find.byKey(const ValueKey('huddle-mute-toggle'))), - const Size.square(72), + const Size.square(80), + ); + expect( + tester.getSize(find.byKey(const ValueKey('huddle-emoji-reactions'))), + const Size.square(80), + ); + expect( + (tester + .widget( + find.byKey(const ValueKey('huddle-call-controls')), + ) + .padding + as EdgeInsets) + .bottom, + 0, + ); + final speakerCenter = tester.getCenter( + find.byKey(const ValueKey('huddle-speaker-toggle')), + ); + final muteCenter = tester.getCenter( + find.byKey(const ValueKey('huddle-mute-toggle')), + ); + final emojiCenter = tester.getCenter( + find.byKey(const ValueKey('huddle-emoji-reactions')), + ); + expect(speakerCenter.dy, closeTo(muteCenter.dy, 0.01)); + expect(emojiCenter.dy, closeTo(muteCenter.dy, 0.01)); + expect( + (speakerCenter.dx + emojiCenter.dx) / 2, + closeTo(muteCenter.dx, 0.01), ); expect(find.text('Miles'), findsNothing); expect(find.text('Pollen'), findsNothing); @@ -3580,11 +3670,60 @@ void main() { transport.emitRemoteAudio(); await tester.pump(); - await tester.pump(); expect( find.bySemanticsLabel(RegExp(r'Miles, speaking')), findsOneWidget, ); + final speakingHalo = tester.widget( + find.byKey(const ValueKey('huddle-speaking-halo-desktop')), + ); + final speakingHaloDecoration = + speakingHalo.decoration! as BoxDecoration; + expect(speakingHaloDecoration.border, isNull); + expect(speakingHaloDecoration.color?.a, closeTo(0.07, 0.001)); + await tester.pump(const Duration(milliseconds: 70)); + final mediumScaleMidTransition = tester + .widget( + find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')), + ) + .transform + .storage + .first; + expect(mediumScaleMidTransition, greaterThan(1)); + expect(mediumScaleMidTransition, lessThan(1.772)); + await tester.pump(const Duration(milliseconds: 70)); + final mediumSpeakingScale = tester + .widget( + find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')), + ) + .transform + .storage + .first; + expect(mediumSpeakingScale, closeTo(1.772, 0.01)); + + transport.emitRemoteAudio(levelDbov: -10, sequence: 2); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 70)); + final loudScaleMidTransition = tester + .widget( + find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')), + ) + .transform + .storage + .first; + expect(loudScaleMidTransition, greaterThan(mediumSpeakingScale)); + expect(loudScaleMidTransition, lessThan(2.291)); + await tester.pump(const Duration(milliseconds: 70)); + final loudSpeakingScale = tester + .widget( + find.byKey(const ValueKey('huddle-speaking-halo-scale-desktop')), + ) + .transform + .storage + .first; + expect(loudSpeakingScale, closeTo(2.291, 0.01)); + expect(loudSpeakingScale, greaterThan(mediumSpeakingScale)); + expect(loudSpeakingScale, lessThanOrEqualTo(2.55)); expect( find.descendant( @@ -3703,6 +3842,32 @@ void main() { isTrue, ); + final emojiIcon = find.descendant( + of: find.byKey(const ValueKey('huddle-emoji-reactions')), + matching: find.byIcon(LucideIcons.smilePlus), + ); + expect(emojiIcon, findsOneWidget); + expect(tester.widget(emojiIcon).size, 28); + expect(find.bySemanticsLabel('Emoji reactions'), findsOneWidget); + final huddleContainer = ProviderScope.containerOf( + tester.element(find.byType(MobileHuddleShell)), + ); + final localBurstController = huddleContainer.read( + emojiBurstControllerProvider, + )..clear(); + final localAvatarCenter = tester.getCenter( + find.byKey(const ValueKey('huddle-speaking-ring-self')), + ); + await tester.tap(find.byKey(const ValueKey('huddle-emoji-reactions'))); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(EmojiPickerSheet), findsOneWidget); + tester + .widget(find.byType(EmojiPickerSheet)) + .onSelect('πŸŽ‰'); + await tester.pump(); + expect(localBurstController.debugLastBurstOrigin, localAvatarCenter); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.byKey(const ValueKey('huddle-minimize'))); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget); @@ -3778,6 +3943,10 @@ void main() { final drawerRect = tester.getRect( find.byKey(const ValueKey('mobile-huddle-drawer')), ); + final drawerOffset = tester.widget( + find.byKey(const ValueKey('huddle-drawer-control-offset')), + ); + expect(drawerOffset.transform.storage[13], -8); final primaryControlsRect = tester.getRect( find.byKey(const ValueKey('huddle-drawer-primary-controls')), ); @@ -3798,7 +3967,7 @@ void main() { ]) { expect( tester.getCenter(find.byKey(key)).dy, - closeTo(drawerControlCenter, 0.01), + closeTo(drawerControlCenter - 8, 0.01), ); } await tester.tap(find.byKey(const ValueKey('huddle-drawer-expand'))); @@ -3841,6 +4010,157 @@ void main() { }, ); + testWidgets( + 'fits a dense Huddle roster without scrolling and shrinks avatars', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // The relay caps a room at 25 peers, so 24 remotes plus the local + // participant exercises the densest supported call. + final remotePubkeys = List.generate(24, (index) => 'guest-$index'); + final transport = _HuddleTestTransport( + peers: const {2: HuddlePeer(pubkey: 'self', peerIndex: 2)}, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'dense-call-layout', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: { + 'self': const UserProfile(pubkey: 'self', displayName: 'Self'), + for (final pubkey in remotePubkeys) + pubkey: UserProfile(pubkey: pubkey, displayName: pubkey), + }, + huddleMembers: [ + for (final pubkey in remotePubkeys) + ChannelMember( + pubkey: pubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + final remoteRegion = find.byKey( + const ValueKey('huddle-remote-participant-region'), + ); + expect( + tester.widget(remoteRegion).heightFactor, + 0.58, + ); + expect( + find.descendant( + of: remoteRegion, + matching: find.byType(SingleChildScrollView), + ), + findsNothing, + ); + for (final pubkey in remotePubkeys) { + expect( + find.byKey(ValueKey('huddle-participant-entry-$pubkey')), + findsOneWidget, + ); + } + + final firstRemoteRing = find.byKey( + const ValueKey('huddle-speaking-ring-guest-0'), + ); + final firstRemoteAvatar = find + .descendant( + of: firstRemoteRing, + matching: find.byType(CircleAvatar), + ) + .first; + expect(tester.getSize(firstRemoteAvatar).width, lessThan(104)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'bursts remote Huddle reactions and ignores the local relay echo', + (tester) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final relaySession = _HuddleReactionRelaySession(); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'reaction-call', + kind: EventKind.huddleStarted, + pubkey: 'desktop', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: relaySession, + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => _HuddleTestTransport(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + for ( + var attempt = 0; + attempt < 20 && relaySession.reactionFilter == null; + attempt++ + ) { + await tester.pump(); + } + + expect( + relaySession.reactionFilter?.kinds, + contains(EventKind.huddleReaction), + ); + expect(relaySession.reactionFilter?.tags['#h'], [_huddleChannelId]); + expect(relaySession.reactionFilter?.since, isNotNull); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MobileHuddleShell)), + ); + final burstController = container.read(emojiBurstControllerProvider); + expect(burstController.hasParticles, isFalse); + final remoteAvatarCenter = tester.getCenter( + find.byKey(const ValueKey('huddle-speaking-ring-desktop')), + ); + + relaySession.emitReaction(pubkey: 'self', emoji: 'πŸŽ‰'); + await tester.pump(); + expect(burstController.hasParticles, isFalse); + + relaySession.emitReaction(pubkey: 'desktop', emoji: 'πŸŽ‰'); + await tester.pump(); + expect(burstController.hasParticles, isTrue); + expect(burstController.debugLastBurstOrigin, remoteAvatarCenter); + }, + ); + testWidgets( 'centers a solo participant and moves them when another person joins', (tester) async { @@ -8279,6 +8599,63 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { } } +class _HuddleReactionRelaySession extends RelaySessionNotifier { + NostrFilter? reactionFilter; + void Function(NostrEvent)? _reactionListener; + var _nextEventId = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async => event; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(EventKind.huddleReaction)) { + reactionFilter = filter; + _reactionListener = onEvent; + return () { + if (identical(_reactionListener, onEvent)) { + _reactionListener = null; + } + }; + } + return () {}; + } + + void emitReaction({required String pubkey, required String emoji}) { + _reactionListener?.call( + NostrEvent( + id: 'huddle-reaction-${_nextEventId++}', + pubkey: pubkey, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + kind: EventKind.huddleReaction, + tags: [ + ['h', _huddleChannelId], + ['reaction', emoji], + ['sender_name', 'Remote'], + ], + content: emoji, + sig: 'sig', + ), + ); + } +} + class _FakeTypingNotifier extends ChannelTypingNotifier { final List _entries; _FakeTypingNotifier(this._entries, {String channelId = _channelId}) @@ -8530,6 +8907,7 @@ final class _HuddleTestMedia implements HuddleMedia { final class _HuddleTestTransport implements HuddleTransportClient { _HuddleTestTransport({ this.connectError, + this.connectGate, this.peers = const { 1: HuddlePeer(pubkey: 'desktop', peerIndex: 1), 2: HuddlePeer(pubkey: 'self', peerIndex: 2), @@ -8537,6 +8915,7 @@ final class _HuddleTestTransport implements HuddleTransportClient { }); final HuddleTransportError? connectError; + final Future? connectGate; final Map peers; final _states = StreamController.broadcast(sync: true); final _remoteFrames = StreamController.broadcast( @@ -8546,14 +8925,14 @@ final class _HuddleTestTransport implements HuddleTransportClient { final _issues = StreamController.broadcast(sync: true); HuddleTransportState _state = HuddleTransportState.idle(); - void emitRemoteAudio() { + void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) { _remoteFrames.add( HuddleRemoteAudioFrame( peerIndex: 1, - header: const HuddleAudioHeader( - sequence: 1, + header: HuddleAudioHeader( + sequence: sequence, timestamp48k: 960, - levelDbov: -30, + levelDbov: levelDbov, flags: 0, ), opusPayload: Uint8List.fromList([1, 2, 3]), @@ -8578,6 +8957,7 @@ final class _HuddleTestTransport implements HuddleTransportClient { @override Future connect() async { + if (connectGate case final gate?) await gate; if (connectError case final error?) throw error; _state = HuddleTransportState( phase: HuddleTransportPhase.connected, diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index be365d804..f7f447d19 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/shared/relay/relay.dart'; /// Tests for [channelDetailsFromEvent]. @@ -274,6 +275,90 @@ void main() { expect(started.id, start.id); }, ); + + test( + 'publishes ephemeral Huddle reactions on the backing channel', + () async { + final keys = nostr.Keys.generate(); + final session = _RecordingPublishRelaySession(); + final actionsProvider = Provider((ref) { + return ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + ); + }); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(keys.public), + ], + ); + addTearDown(container.dispose); + + await container + .read(actionsProvider) + .sendHuddleReaction( + channelId: _channelId, + emoji: ' πŸŽ‰ ', + senderName: 'Self', + ); + + expect(session.publishedEvents, hasLength(1)); + final reaction = session.publishedEvents.single; + expect(reaction.kind, EventKind.huddleReaction); + expect(reaction.content, 'πŸŽ‰'); + expect(reaction.tags, [ + ['h', _channelId], + ['reaction', 'πŸŽ‰'], + ['sender_name', 'Self'], + ]); + }, + ); + + test('last-human count ignores a stale cached roster', () async { + final session = _ConnectionAwareRelaySession(); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + channelMembersProvider(_channelId).overrideWith( + (ref) async => [ + ChannelMember( + pubkey: _memberPubkey, + role: 'admin', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ], + ); + addTearDown(container.dispose); + container.read(relaySessionProvider); + session.connect(); + expect( + await container.read(channelMembersProvider(_channelId).future), + hasLength(2), + ); + + final humanCount = await container.read(huddleHumanCountProvider)( + _channelId, + ); + + expect(humanCount, 1); + expect(session.historyQueryCount, 1); + }); }); group('channelMembersProvider', () { diff --git a/mobile/test/shared/huddle/huddle_session_test.dart b/mobile/test/shared/huddle/huddle_session_test.dart index 6b8656b25..a2efc7b9d 100644 --- a/mobile/test/shared/huddle/huddle_session_test.dart +++ b/mobile/test/shared/huddle/huddle_session_test.dart @@ -60,6 +60,10 @@ void main() { container.read(huddleSessionProvider).activeSpeakerPubkeys, contains('desktop'), ); + expect( + container.read(huddleSessionProvider).speakerLevels['desktop'], + closeTo(25 / 55, 0.001), + ); media.emitLocal( HuddleLocalAudioFrame( @@ -76,6 +80,10 @@ void main() { expect(transport.sentFrames.single.opusPayload, [4, 5]); expect(container.read(huddleSessionProvider).sentFrameCount, 1); expect(container.read(huddleSessionProvider).isMuted, isFalse); + expect( + container.read(huddleSessionProvider).speakerLevels['mobile'], + closeTo(35 / 55, 0.001), + ); await controller.setMuted(true); media.emitLocal( diff --git a/mobile/test/shared/theme/app_theme_test.dart b/mobile/test/shared/theme/app_theme_test.dart index f038c6d97..161d2cd80 100644 --- a/mobile/test/shared/theme/app_theme_test.dart +++ b/mobile/test/shared/theme/app_theme_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/app_colors.dart'; import 'package:buzz/shared/theme/app_theme.dart'; void main() { @@ -24,4 +25,16 @@ void main() { expect(side.color, Colors.black.withValues(alpha: 0.04)); expect(side.width, 1); }); + + test('keeps inactive Huddle controls distinct in dark mode', () { + final colors = AppTheme.dark().extension()!; + + expect(colors.huddleControlSurface, isNot(colors.huddleDrawerSurface)); + expect( + (colors.huddleControlSurface.computeLuminance() - + colors.huddleDrawerSurface.computeLuminance()) + .abs(), + greaterThan(0.02), + ); + }); } diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart index 7498f297c..e414ccab6 100644 --- a/mobile/test/shared/widgets/modal_presentation_test.dart +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -96,6 +96,7 @@ void main() { home: const ConcentricSheetSurface( enabled: true, color: Colors.red, + backdropColor: Colors.black, corners: ConcentricSurfaceCorners.bottom, padding: EdgeInsets.zero, providesSheetSurface: false, @@ -107,6 +108,10 @@ void main() { final nativeSurface = tester.widget(find.byType(UiKitView)); expect(nativeSurface.creationParams, containsPair('corners', 'bottom')); + expect( + nativeSurface.creationParams, + containsPair('backdropColor', Colors.black.toARGB32()), + ); final contentClip = tester.widget( find.byKey(const ValueKey('concentric-sheet-content-clip')), ); @@ -124,6 +129,92 @@ void main() { }, ); + testWidgets('native surface colors follow live theme changes', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const supportChannel = MethodChannel('buzz/concentric_sheet_surface'); + const viewChannel = MethodChannel('buzz/concentric_sheet_surface/42'); + final colorUpdates = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + supportChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + viewChannel, + (call) async { + colorUpdates.add(call); + return null; + }, + ); + + Widget themedSurface(ThemeData theme) => MaterialApp( + theme: theme, + home: Builder( + builder: (context) => ConcentricSheetSurface( + enabled: true, + color: context.colors.surface, + backdropColor: context.appColors.huddleDrawerSurface, + corners: ConcentricSurfaceCorners.bottom, + padding: EdgeInsets.zero, + providesSheetSurface: false, + child: const SizedBox(height: 80, child: Text('App surface')), + ), + ), + ); + + try { + final darkTheme = AppTheme.dark(); + await tester.pumpWidget(themedSurface(darkTheme)); + await tester.pump(); + tester.widget(find.byType(UiKitView)).onPlatformViewCreated!( + 42, + ); + await tester.pump(); + + expect(colorUpdates, hasLength(1)); + expect(colorUpdates.single.method, 'updateColors'); + expect( + colorUpdates.single.arguments, + containsPair('color', darkTheme.colorScheme.surface.toARGB32()), + ); + expect( + colorUpdates.single.arguments, + containsPair( + 'backdropColor', + darkTheme.extension()!.huddleDrawerSurface.toARGB32(), + ), + ); + + final lightTheme = AppTheme.light(); + await tester.pumpWidget(themedSurface(lightTheme)); + await tester.pumpAndSettle(); + + expect(colorUpdates.length, greaterThanOrEqualTo(2)); + expect( + colorUpdates.last.arguments, + containsPair('color', lightTheme.colorScheme.surface.toARGB32()), + ); + expect( + colorUpdates.last.arguments, + containsPair( + 'backdropColor', + lightTheme.extension()!.huddleDrawerSurface.toARGB32(), + ), + ); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + supportChannel, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + viewChannel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('native titled sheets leave the concentric surface unobscured', ( tester, ) async {