refactor chat interaction handling, improve gesture detection, restore chat delay input indicator

This commit is contained in:
Tommy Chow 2026-01-02 19:51:58 -05:00
parent 8b54b3ba1b
commit 359509ec52
3 changed files with 151 additions and 138 deletions

View file

@ -31,18 +31,21 @@ class Chat extends StatelessWidget {
return Column(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.unfocusInput();
}
},
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
// Wrap only the message list with GestureDetector
// so taps on ChatBottomBar don't trigger unfocus
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
} else if (chatStore.textFieldFocusNode.hasFocus) {
chatStore.unfocusInput();
}
},
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: chatStore.settings.messageScale.textScaler,
),
@ -93,68 +96,66 @@ class Chat extends StatelessWidget {
),
),
),
// Prevents accidental chat scrolling when swiping down from the top edge
// to access system UI (Notification Center/Control Center) in landscape mode.
if (context.isLandscape)
Positioned(
top: 0,
left: 0,
right: 0,
height: 24,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {},
),
),
),
// Prevents accidental chat scrolling when swiping down from the top edge
// to access system UI (Notification Center/Control Center) in landscape mode.
if (context.isLandscape)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
height: 24,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {},
),
),
AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
68 +
(chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon: const Icon(
Icons.arrow_downward_rounded,
),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ChatBottomBar(
chatStore: chatStore,
onAddChat: onAddChat,
),
),
AnimatedPadding(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(
left: 4,
top: 4,
right: 4,
bottom:
68 +
(chatStore.assetsStore.showEmoteMenu
? 0
: MediaQuery.of(context).padding.bottom),
),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.arrow_downward_rounded),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
),
),
),
],
),
),
],
),
),
AnimatedContainer(

View file

@ -84,10 +84,10 @@ class ChatBottomBar extends StatelessWidget {
final isFullscreenOverlay =
chatStore.settings.fullScreen && context.isLandscape;
// Check if chat delay is active (for indicator only, doesn't block input)
final hasChatDelay =
chatStore.settings.showVideo && chatStore.settings.chatDelay > 0;
final delayTooltipMessage =
'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)';
const loginTooltipMessage = 'Log in to chat';
final bottomBarContent = Column(
@ -128,40 +128,44 @@ class ChatBottomBar extends StatelessWidget {
),
),
],
// Wrap autocomplete sections with TextFieldTapRegion so taps
// on them don't trigger TextField's onTapOutside callback.
if (chatStore.settings.autocomplete &&
chatStore.showEmoteAutocomplete &&
matchingEmotes.isNotEmpty) ...[
const Divider(),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: matchingEmotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(
matchingEmotes[index],
autocompleteMode: true,
),
onLongPress: () {
HapticFeedback.lightImpact();
TextFieldTapRegion(
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: matchingEmotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(
matchingEmotes[index],
autocompleteMode: true,
),
onLongPress: () {
HapticFeedback.lightImpact();
IRCMessage.showEmoteDetailsBottomSheet(
context,
emote: matchingEmotes[index],
launchExternal: chatStore.settings.launchUrlExternal,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
useFade: false,
height:
matchingEmotes[index].height?.toDouble() ??
defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
IRCMessage.showEmoteDetailsBottomSheet(
context,
emote: matchingEmotes[index],
launchExternal: chatStore.settings.launchUrlExternal,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
useFade: false,
height:
matchingEmotes[index].height?.toDouble() ??
defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
),
),
),
),
@ -173,30 +177,32 @@ class ChatBottomBar extends StatelessWidget {
chatStore.showMentionAutocomplete &&
matchingChatters.isNotEmpty) ...[
const Divider(),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: matchingChatters.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
),
onPressed: () {
final split = chatStore.textController.text.split(' ')
..removeLast()
..add('@${matchingChatters[index]} ');
TextFieldTapRegion(
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: matchingChatters.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
),
onPressed: () {
final split = chatStore.textController.text.split(' ')
..removeLast()
..add('@${matchingChatters[index]} ');
chatStore.textController.text = split.join(' ');
chatStore.textController.selection =
TextSelection.fromPosition(
TextPosition(
offset: chatStore.textController.text.length,
),
);
},
child: Text(matchingChatters[index]),
chatStore.textController.text = split.join(' ');
chatStore.textController.selection =
TextSelection.fromPosition(
TextPosition(
offset: chatStore.textController.text.length,
),
);
},
child: Text(matchingChatters[index]),
),
),
),
),
@ -211,16 +217,14 @@ class ChatBottomBar extends StatelessWidget {
context.isLandscape)
Builder(
builder: (context) {
final isDisabled =
!chatStore.auth.isLoggedIn || hasChatDelay;
final isDisabled = !chatStore.auth.isLoggedIn;
return GestureDetector(
onTap: isDisabled
? () {
final message = hasChatDelay
? delayTooltipMessage
: loginTooltipMessage;
chatStore.updateNotification(message);
chatStore.updateNotification(
loginTooltipMessage,
);
}
: null,
child: IconButton(
@ -242,16 +246,14 @@ class ChatBottomBar extends StatelessWidget {
builder: (context) {
final isDisabled =
!chatStore.auth.isLoggedIn ||
chatStore.isSendingMessage ||
hasChatDelay;
chatStore.isSendingMessage;
return GestureDetector(
onTap: isDisabled && !chatStore.isSendingMessage
? () {
final message = hasChatDelay
? delayTooltipMessage
: loginTooltipMessage;
chatStore.updateNotification(message);
chatStore.updateNotification(
loginTooltipMessage,
);
}
: null,
child: TextField(
@ -259,7 +261,7 @@ class ChatBottomBar extends StatelessWidget {
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 3,
// Disable text field when sending message, when not logged in, or when chat delay is active
// Disable text field when sending message or when not logged in
enabled: !isDisabled,
decoration: InputDecoration(
prefixIcon:
@ -283,6 +285,8 @@ class ChatBottomBar extends StatelessWidget {
? 'Sending...'
: chatStore.replyingToMessage != null
? 'Reply'
: hasChatDelay
? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)'
: 'Chat'
: loginTooltipMessage,
),
@ -317,8 +321,7 @@ class ChatBottomBar extends StatelessWidget {
: const Icon(Icons.send_rounded),
onPressed:
chatStore.auth.isLoggedIn &&
!chatStore.isSendingMessage &&
!hasChatDelay
!chatStore.isSendingMessage
? () => chatStore.sendMessage(
chatStore.textController.text,
)

View file

@ -74,14 +74,23 @@ class ChatTabs extends StatelessWidget {
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
// If pressing the back button on Android while the emote menu
// is open, close it instead of going back to the streams list.
// On Android back gesture, close overlays first before navigating back
final activeStore = chatTabsStore.activeChatStore;
// Priority 1: Close emote menu
if (activeStore.assetsStore.showEmoteMenu) {
activeStore.assetsStore.showEmoteMenu = false;
} else {
Navigator.of(context).pop();
return;
}
// Priority 2: Unfocus keyboard
if (activeStore.textFieldFocusNode.hasFocus) {
activeStore.unfocusInput();
return;
}
// Priority 3: Navigate back
Navigator.of(context).pop();
},
child: Stack(
children: [