part of '../message_group.dart'; class _HoverActions extends StatefulWidget { final PyramidTheme pt; final Event event; final List events; final Room? room; final String currentUserId; final VoidCallback? onReply; final ValueChanged? onOverlayStateChanged; const _HoverActions({ required this.pt, required this.event, required this.events, required this.currentUserId, this.room, this.onReply, this.onOverlayStateChanged, }); @override State<_HoverActions> createState() => _HoverActionsState(); } class _HoverActionsState extends State<_HoverActions> { OverlayEntry? _reactionOverlay; List _quickReactions = ReactionService.quickReactions; @override void initState() { super.initState(); ReactionService.load().then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); } void _closeReactionOverlay() { _reactionOverlay?.remove(); _reactionOverlay = null; widget.onOverlayStateChanged?.call(false); } void _toggleReactionPicker() { if (_reactionOverlay != null) { _closeReactionOverlay(); return; } final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; _reactionOverlay = OverlayEntry(builder: (ctx) { final screenSize = MediaQuery.sizeOf(ctx); const pickerW = 340.0; const pickerH = 380.0; double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8); double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); return Material( type: MaterialType.transparency, child: Stack(children: [ GestureDetector( onTap: _closeReactionOverlay, behavior: HitTestBehavior.translucent, child: const SizedBox.expand(), ), Positioned( left: left, top: top, child: EmojiPicker( onEmojiSelected: (emoji) { _closeReactionOverlay(); widget.room?.sendReaction(widget.event.eventId, emoji) .catchError((_) => ''); ReactionService.recordUsed(emoji).then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); }, onClose: _closeReactionOverlay, ), ), ]), ); }); widget.onOverlayStateChanged?.call(true); Overlay.of(context).insert(_reactionOverlay!); } void _showMoreMenu() { final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; final size = box?.size ?? Size.zero; final isOwnMessage = widget.event.senderId == widget.currentUserId; final text = widget.event.body; // Find any image or sticker event in the group that can be saved as a sticker. final imageEvent = widget.events.firstWhereOrNull( (e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker, ); // Capture messenger synchronously before showMenu's async gap so the // snackbar can be shown even if the widget is unmounted by then. final messenger = ScaffoldMessenger.maybeOf(context); showMenu( context: context, position: RelativeRect.fromLTRB( offset.dx, offset.dy - size.height, offset.dx + size.width, offset.dy, ), items: [ PopupMenuItem(value: 'copy', child: Row(children: [ Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), if (imageEvent != null) PopupMenuItem(value: 'save_sticker', child: Row(children: [ Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), if (isOwnMessage) PopupMenuItem(value: 'delete', child: Row(children: [ Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger), const SizedBox(width: 10), Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)), ])), PopupMenuItem(value: 'pin', child: Row(children: [ Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), ], ).then((value) async { if (value == 'copy') { Clipboard.setData(ClipboardData(text: text)); } else if (value == 'save_sticker' && imageEvent != null) { final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent); messenger?.showSnackBar(SnackBar( content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'), duration: const Duration(seconds: 2), )); } else if (value == 'delete') { widget.event.redactEvent(reason: 'Vom Nutzer gelöscht') .catchError((_) => null); } else if (value == 'pin') { final room = widget.room; if (room == null) return; final pinned = List.from( room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], ); final eventId = widget.event.eventId; if (!pinned.contains(eventId)) pinned.add(eventId); room.setPinnedEvents(pinned).catchError((_) => ''); } }); } @override void dispose() { if (_reactionOverlay != null) { _reactionOverlay!.remove(); _reactionOverlay = null; widget.onOverlayStateChanged?.call(false); } super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; return Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: pt.bg0, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(100), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ // Quick reactions ..._quickReactions.map((emoji) => _QuickReactionBtn( emoji: emoji, pt: pt, onTap: () { widget.room?.sendReaction(widget.event.eventId, emoji) .catchError((_) => ''); ReactionService.recordUsed(emoji).then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); }, )), Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)), _ActionBtn( icon: Icons.add_reaction_outlined, tooltip: 'Reaktion', pt: pt, accent: true, onTap: _toggleReactionPicker, ), _ActionBtn( icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: widget.onReply, ), _ActionBtn( icon: Icons.more_horiz_rounded, tooltip: 'Mehr', pt: pt, onTap: _showMoreMenu, ), ], ), ); } } class _ActionBtn extends StatefulWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final bool accent; final VoidCallback? onTap; const _ActionBtn({ required this.icon, required this.tooltip, required this.pt, this.accent = false, this.onTap, }); @override State<_ActionBtn> createState() => _ActionBtnState(); } class _ActionBtnState extends State<_ActionBtn> { bool _hovered = false; @override Widget build(BuildContext context) { final color = _hovered ? (widget.accent ? widget.pt.accent : widget.pt.fg) : widget.pt.fgMuted; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Tooltip( message: widget.tooltip, waitDuration: const Duration(milliseconds: 400), child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 32, height: 32, decoration: BoxDecoration( color: _hovered ? widget.pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(widget.pt.rSm), ), child: Center( child: Icon(widget.icon, size: 16, color: color), ), ), ), ), ); } }