diff --git a/client/lib/features/chat/presentation/widgets/message_display.dart b/client/lib/features/chat/presentation/widgets/message_display.dart index c68ef0d10..edafe95bb 100644 --- a/client/lib/features/chat/presentation/widgets/message_display.dart +++ b/client/lib/features/chat/presentation/widgets/message_display.dart @@ -34,7 +34,17 @@ class MessageDisplay extends StatefulWidget { } class MessageDisplayState extends State { - bool _isHovered = false; + // Use ValueNotifier so hover-state changes only rebuild the Container's + // color decoration, not the entire widget tree (which would cause + // UserInfoBuilder to reload and briefly show '...' for the display name + // and cause the admin badge to flicker/duplicate in the Wrap layout). + final _isHovered = ValueNotifier(false); + + @override + void dispose() { + _isHovered.dispose(); + super.dispose(); + } bool get _canDelete => EventPermissionsProvider.watch(context) @@ -59,139 +69,144 @@ class MessageDisplayState extends State { return UserInfoBuilder( userId: widget.message.creatorId, builder: (_, isLoading, snapshot) { - return MouseRegion( - onEnter: (hover) => setState(() => _isHovered = true), - onExit: (hover) => setState(() => _isHovered = false), - child: Semantics( - label: context.l10n.chatMessage, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), - color: _isHovered - ? context.theme.colorScheme.scrim.withScrimOpacity - : null, - child: Row( + // Build the message content once. Passed as the static `child` of + // ValueListenableBuilder so it is not recreated on hover changes. + final content = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserProfileChip( + userId: widget.message.creatorId, + showName: false, + imageHeight: 40, + ), + SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - UserProfileChip( - userId: widget.message.creatorId, - showName: false, - imageHeight: 40, - ), - SizedBox(width: 8), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SelectableText( - semanticsLabel: context.l10n.messageFrom, - snapshot.data?.displayName ?? '...', - style: - context.theme.textTheme.bodyLarge!.copyWith( - color: Theme.of(context).isDark - ? context - .theme.colorScheme.onPrimaryContainer - : context.theme.colorScheme.primary, - ), - ), - SelectableText( - semanticsLabel: context.l10n.messageTime, - ' $messageDate, $messageTime$messageTimeZone', - style: - context.theme.textTheme.bodyMedium!.copyWith( - color: Theme.of(context).isDark - ? context - .theme.colorScheme.onPrimaryContainer - : context.theme.colorScheme.primary, - ), - ), - if (isMod) - Container( - color: Theme.of(context).primaryColor, - margin: const EdgeInsets.symmetric( - horizontal: 4, - ), - padding: const EdgeInsets.symmetric( - vertical: 3, - horizontal: 4, - ), - child: HeightConstrainedText( - isAdmin ? 'ADMIN' : 'MOD', - style: context.theme.textTheme.labelMedium! - .copyWith( - color: context.theme.colorScheme.onPrimary, - ), - ), - ), - if (widget.isSending) - Container( - margin: const EdgeInsets.only(left: 4), - height: 14, - width: 14, - alignment: Alignment.center, - child: CustomLoadingIndicator(), - ), - ], + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SelectableText( + semanticsLabel: context.l10n.messageFrom, + snapshot.data?.displayName ?? '...', + style: context.theme.textTheme.bodyLarge!.copyWith( + color: Theme.of(context).isDark + ? context.theme.colorScheme.onPrimaryContainer + : context.theme.colorScheme.primary, ), - SizedBox(height: 4), - if (_removed) - HeightConstrainedText( - 'This message was removed.', - style: context.theme.textTheme.bodyMedium!.copyWith( - color: Theme.of(context).isDark - ? context.theme.colorScheme.onPrimaryContainer - : context.theme.colorScheme.secondary, - fontStyle: FontStyle.italic, - ), - ) - else - // Note: There are highlighting issues due to the below - // https://github.com/Cretezy/flutter_linkify/issues/59 - // https://github.com/Cretezy/flutter_linkify/issues/54 - Semantics( - label: context.l10n.chatMessage, - child: SelectableLinkify( - text: widget.message.message ?? '', - style: - context.theme.textTheme.bodyLarge!.copyWith( - color: Theme.of(context).isDark - ? context.theme.colorScheme.onPrimary - : context.theme.colorScheme.primary, - ), - options: LinkifyOptions(looseUrl: true), - onOpen: (link) async { - await launch(link.url); - }, + ), + SelectableText( + semanticsLabel: context.l10n.messageTime, + ' $messageDate, $messageTime$messageTimeZone', + style: context.theme.textTheme.bodyMedium!.copyWith( + color: Theme.of(context).isDark + ? context.theme.colorScheme.onPrimaryContainer + : context.theme.colorScheme.primary, + ), + ), + if (isMod) + Container( + color: Theme.of(context).primaryColor, + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric( + vertical: 3, + horizontal: 4, + ), + child: HeightConstrainedText( + isAdmin ? 'ADMIN' : 'MOD', + style: context.theme.textTheme.labelMedium! + .copyWith( + color: context.theme.colorScheme.onPrimary, ), ), - ], - ), + ), + if (widget.isSending) + Container( + margin: const EdgeInsets.only(left: 4), + height: 14, + width: 14, + alignment: Alignment.center, + child: CustomLoadingIndicator(), + ), + ], ), - if (!_removed && _canDelete) - CustomInkWell( - onTap: () => alertOnError( - context, - () => Provider.of(context, listen: false) - .removeChatMessage(widget.message), + SizedBox(height: 4), + if (_removed) + HeightConstrainedText( + 'This message was removed.', + style: context.theme.textTheme.bodyMedium!.copyWith( + color: Theme.of(context).isDark + ? context.theme.colorScheme.onPrimaryContainer + : context.theme.colorScheme.secondary, + fontStyle: FontStyle.italic, ), - borderRadius: BorderRadius.circular(15), - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon( - Icons.close, + ) + else + // Note: There are highlighting issues due to the below + // https://github.com/Cretezy/flutter_linkify/issues/59 + // https://github.com/Cretezy/flutter_linkify/issues/54 + Semantics( + label: context.l10n.chatMessage, + child: SelectableLinkify( + text: widget.message.message ?? '', + style: context.theme.textTheme.bodyLarge!.copyWith( color: Theme.of(context).isDark - ? context.theme.colorScheme.surface + ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.primary, - size: 20, ), + options: LinkifyOptions(looseUrl: true), + onOpen: (link) async { + await launch(link.url); + }, ), ), ], ), ), + if (!_removed && _canDelete) + CustomInkWell( + onTap: () => alertOnError( + context, + () => Provider.of(context, listen: false) + .removeChatMessage(widget.message), + ), + borderRadius: BorderRadius.circular(15), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.close, + color: Theme.of(context).isDark + ? context.theme.colorScheme.surface + : context.theme.colorScheme.primary, + size: 20, + ), + ), + ), + ], + ); + + return MouseRegion( + onEnter: (_) => _isHovered.value = true, + onExit: (_) => _isHovered.value = false, + child: Semantics( + label: context.l10n.chatMessage, + child: ValueListenableBuilder( + valueListenable: _isHovered, + // `child` is the pre-built content Row — Flutter reuses it + // unchanged when the ValueNotifier fires, so only the Container + // decoration is re-evaluated on hover. + child: content, + builder: (context, isHovered, child) => Container( + padding: + const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + color: isHovered + ? context.theme.colorScheme.scrim.withScrimOpacity + : null, + child: child, + ), + ), ), ); },