add inline emote support in chat text field

This commit is contained in:
Tommy Chow 2026-01-02 21:51:50 -05:00
parent 359509ec52
commit 23fee7bd4e
4 changed files with 149 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
@ -8,6 +9,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/context_extensions.dart';
import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart';
import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/blurred_container.dart';
import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart';
class ChatBottomBar extends StatelessWidget { class ChatBottomBar extends StatelessWidget {
@ -256,13 +258,20 @@ class ChatBottomBar extends StatelessWidget {
); );
} }
: null, : null,
child: TextField( child: ExtendedTextField(
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
focusNode: chatStore.textFieldFocusNode, focusNode: chatStore.textFieldFocusNode,
minLines: 1, minLines: 1,
maxLines: 3, maxLines: 3,
// Disable text field when sending message or when not logged in // Disable text field when sending message or when not logged in
enabled: !isDisabled, enabled: !isDisabled,
specialTextSpanBuilder: EmoteTextSpanBuilder(
emoteToObject: chatStore.assetsStore.emoteToObject,
userEmoteToObject:
chatStore.assetsStore.userEmoteToObject,
emoteSize:
chatStore.settings.emoteScale * defaultEmoteSize,
),
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: prefixIcon:
chatStore.settings.emoteMenuButtonOnLeft chatStore.settings.emoteMenuButtonOnLeft

View file

@ -0,0 +1,122 @@
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/widgets/frosty_cached_network_image.dart';
/// A [SpecialTextSpanBuilder] that renders emote names as inline images.
///
/// This builder parses input text word-by-word and converts recognized emote
/// names into [ExtendedWidgetSpan] widgets displaying the emote image.
/// Unrecognized words remain as regular text.
class EmoteTextSpanBuilder extends SpecialTextSpanBuilder {
/// Map of emote names to their [Emote] objects (global + channel emotes).
final Map<String, Emote> emoteToObject;
/// Map of user-owned emotes (subscriber, bits, unlocked).
final Map<String, Emote> userEmoteToObject;
/// The height to render emotes at.
final double emoteSize;
EmoteTextSpanBuilder({
required this.emoteToObject,
required this.userEmoteToObject,
required this.emoteSize,
});
@override
TextSpan build(
String data, {
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
}) {
if (data.isEmpty) {
return TextSpan(text: '', style: textStyle);
}
final spans = <InlineSpan>[];
var currentIndex = 0;
// Split by spaces but preserve space positions
final parts = _splitPreservingSpaces(data);
for (final part in parts) {
if (part == ' ') {
// Add space as regular text
spans.add(TextSpan(text: ' ', style: textStyle));
currentIndex += 1;
} else if (part.isNotEmpty) {
// Check if this word is an emote - render immediately on exact match
final emote = emoteToObject[part] ?? userEmoteToObject[part];
if (emote != null) {
spans.add(_createEmoteSpan(emote, part, currentIndex));
} else {
spans.add(TextSpan(text: part, style: textStyle));
}
currentIndex += part.length;
}
}
return TextSpan(children: spans, style: textStyle);
}
/// Splits text into words and spaces, preserving the spaces as separate elements.
List<String> _splitPreservingSpaces(String text) {
final result = <String>[];
final buffer = StringBuffer();
for (var i = 0; i < text.length; i++) {
final char = text[i];
if (char == ' ') {
if (buffer.isNotEmpty) {
result.add(buffer.toString());
buffer.clear();
}
result.add(' ');
} else {
buffer.write(char);
}
}
if (buffer.isNotEmpty) {
result.add(buffer.toString());
}
return result;
}
/// Creates an [ExtendedWidgetSpan] for the given emote.
ExtendedWidgetSpan _createEmoteSpan(Emote emote, String text, int start) {
// Calculate dimensions - use emote's native size if available,
// otherwise use the configured emoteSize
final height = emote.height?.toDouble() ?? emoteSize;
final width = emote.width?.toDouble();
return ExtendedWidgetSpan(
actualText: text,
start: start,
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: FrostyCachedNetworkImage(
imageUrl: emote.url,
height: height,
width: width,
useFade: false,
),
),
);
}
@override
SpecialText? createSpecialText(
String flag, {
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap,
int? index,
}) {
// We handle all parsing in the build method, so this returns null
return null;
}
}

View file

@ -305,6 +305,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
extended_text_field:
dependency: "direct main"
description:
name: extended_text_field
sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d"
url: "https://pub.dev"
source: hosted
version: "16.0.2"
extended_text_library:
dependency: transitive
description:
name: extended_text_library
sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba"
url: "https://pub.dev"
source: hosted
version: "12.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -69,6 +69,7 @@ dependencies:
path_provider: ^2.1.5 path_provider: ^2.1.5
dio: ^5.9.0 dio: ^5.9.0
app_links: ^7.0.0 app_links: ^7.0.0
extended_text_field: ^16.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: