From 5e28b40c5cca6863e479b61e71fb50059269fa07 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 15 May 2025 10:53:58 +0100 Subject: [PATCH] feat: chat mentions closes #17 --- lib/widgets/chat_write.dart | 126 +++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/lib/widgets/chat_write.dart b/lib/widgets/chat_write.dart index a76fcfc..dd57902 100644 --- a/lib/widgets/chat_write.dart +++ b/lib/widgets/chat_write.dart @@ -1,8 +1,12 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip19/nip19.dart'; import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/avatar.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; class WriteMessageWidget extends StatefulWidget { final StreamEvent stream; @@ -15,11 +19,128 @@ class WriteMessageWidget extends StatefulWidget { class __WriteMessageWidget extends State { late final TextEditingController _controller; + OverlayEntry? _entry; + late FocusNode _focusNode; @override void initState() { super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(() { + if (!_focusNode.hasFocus && _entry != null) { + _entry!.remove(); + _entry = null; + } + }); _controller = TextEditingController(); + _controller.addListener(() { + if (_controller.text.endsWith("@")) { + // start auto-complete + _showAutoComplete(); + } + }); + } + + @override + void dispose() { + if (_entry != null) { + _entry!.remove(); + } + _controller.dispose(); + super.dispose(); + } + + void _showAutoComplete() { + if (_entry != null) { + _entry!.remove(); + _entry = null; + } + + final pos = context.findRenderObject() as RenderBox?; + _entry = OverlayEntry( + builder: (context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, v, _) { + final selectionStart = v.text.lastIndexOf("@"); + if (selectionStart == -1) { + _entry!.remove(); + _entry = null; + return SizedBox(); + } + final search = v.text.substring(selectionStart + 1, v.text.length); + if (search.isEmpty) { + return SizedBox(); + } + return Stack( + children: [ + Positioned( + left: 0, + bottom: (pos?.paintBounds.bottom ?? 0), + width: MediaQuery.of(context).size.width, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + color: LAYER_2, + borderRadius: DEFAULT_BR, + ), + child: FutureBuilder( + future: ndkCache.searchMetadatas(search, 5), + builder: (context, state) { + if (state.data?.isEmpty ?? true) { + return Text("No user found"); + } + + return Column( + spacing: 4, + children: + (state.data ?? []) + .groupListsBy((m) => m.pubKey) + .entries + .map( + (m) => GestureDetector( + onTap: () { + // replace search string with npub + _controller + .text = _controller.text.replaceRange( + selectionStart, + _controller.text.length, + "nostr:${Nip19.encodePubKey(m.value.first.pubKey)}", + ); + _entry!.remove(); + _entry = null; + }, + child: Row( + spacing: 4, + children: [ + AvatarWidget( + profile: m.value.first, + size: 30, + ), + Expanded( + child: Text( + ProfileNameWidget.nameFromProfile( + m.value.first, + ), + ), + ), + ], + ), + ), + ) + .toList(), + ); + }, + ), + ), + ), + ], + ); + }, + ); + }, + ); + Overlay.of(context).insert(_entry!); } Future _sendMessage(BuildContext context) async { @@ -35,7 +156,7 @@ class __WriteMessageWidget extends State { ], ); _controller.clear(); - FocusScope.of(context).unfocus(); + _focusNode.unfocus(); final res = ndk.broadcast.broadcast(nostrEvent: chatMsg); await res.broadcastDoneFuture; } @@ -55,6 +176,9 @@ class __WriteMessageWidget extends State { children: [ Expanded( child: TextField( + maxLines: 3, + minLines: 1, + focusNode: _focusNode, controller: _controller, onSubmitted: (_) => _sendMessage(context), decoration: InputDecoration(