make message flash when tapping reply

This commit is contained in:
Henry Hiles 2026-03-18 21:49:43 -04:00
commit a6aee7565a
No known key found for this signature in database
5 changed files with 127 additions and 102 deletions

View file

@ -66,6 +66,7 @@ class MessageController extends AsyncNotifier<Message?> {
), ),
).future, ).future,
), ),
"flashing": false,
"timelineId": event.timelineRowId, "timelineId": event.timelineRowId,
"big": event.localContent?.bigEmoji == true, "big": event.localContent?.bigEmoji == true,
"eventType": type, "eventType": type,

View file

@ -1,3 +1,5 @@
import "dart:async";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
@ -282,6 +284,16 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
Future<void> scrollToMessage(Message message) async { Future<void> scrollToMessage(Message message) async {
final controller = await future; final controller = await future;
Future<void> setFlashing(bool flashing) => controller.updateMessage(
message,
message.copyWith(
metadata: {...(message.metadata ?? {}), "flashing": flashing},
),
);
await setFlashing(true);
Timer(Duration(seconds: 1), () => setFlashing(false));
return await controller.scrollToMessage(message.id); return await controller.scrollToMessage(message.id);
} }

View file

@ -9,34 +9,46 @@ class MessageWrapper extends StatelessWidget {
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override @override
Widget build(BuildContext context) => Row( Widget build(BuildContext context) => ClipRRect(
spacing: 8, borderRadius: BorderRadius.all(Radius.circular(12)),
crossAxisAlignment: CrossAxisAlignment.start, child: AnimatedContainer(
children: [ padding: message.metadata?["flashing"] == true
groupStatus?.isFirst != false ? EdgeInsets.all(8)
? AvatarOrHash( : EdgeInsets.all(0),
Uri.parse(message.metadata?["avatarUrl"] ?? ""), color: message.metadata?["flashing"] == true
height: 40, ? Theme.of(context).colorScheme.onSurface.withAlpha(50)
message.metadata?["displayName"] ?? "", : Colors.transparent,
) duration: Duration(milliseconds: 250),
: SizedBox(width: 40), child: Row(
Expanded( spacing: 8,
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
spacing: 4, groupStatus?.isFirst != false
children: [ ? AvatarOrHash(
if (groupStatus?.isFirst != false) Uri.parse(message.metadata?["avatarUrl"] ?? ""),
Text( height: 40,
message.metadata?["displayName"] ?? message.authorId, message.metadata?["displayName"] ?? "",
overflow: TextOverflow.ellipsis, )
style: Theme.of( : SizedBox(width: 40),
context, Expanded(
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child, spacing: 4,
], children: [
), if (groupStatus?.isFirst != false)
Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
child,
],
),
),
],
), ),
], ),
); );
} }

View file

@ -259,18 +259,13 @@ class RoomChat extends HookConsumerWidget {
index, { index, {
required bool isSentByMe, required bool isSentByMe,
MessageGroupStatus? groupStatus, MessageGroupStatus? groupStatus,
}) => MessageWrapper( }) => TextMessageWrapper(
message, message,
TextMessageWrapper( content: message.text,
message, groupStatus: groupStatus,
content: message.text, onTapReply: notifier.scrollToMessage,
groupStatus: groupStatus, updateMessage: controller.updateMessage,
onTapReply: notifier.scrollToMessage, isSentByMe: isSentByMe,
updateMessage: controller.updateMessage,
isSentByMe: isSentByMe,
),
groupStatus,
), ),
imageMessageBuilder: imageMessageBuilder:

View file

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/message_wrapper.dart";
import "package:nexus/widgets/chat_page/top_widget.dart"; import "package:nexus/widgets/chat_page/top_widget.dart";
class TextMessageWrapper extends StatelessWidget { class TextMessageWrapper extends StatelessWidget {
@ -31,75 +32,79 @@ class TextMessageWrapper extends StatelessWidget {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final textMessage = message is TextMessage ? message as TextMessage : null; final textMessage = message is TextMessage ? message as TextMessage : null;
return ClipRRect( return MessageWrapper(
borderRadius: BorderRadius.all(Radius.circular(8)), message,
child: Container( ClipRRect(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), borderRadius: BorderRadius.all(Radius.circular(8)),
decoration: BoxDecoration( child: Container(
color: isSentByMe padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
? colorScheme.primaryContainer decoration: BoxDecoration(
: colorScheme.surfaceContainer, color: isSentByMe
), ? colorScheme.primaryContainer
child: Column( : colorScheme.surfaceContainer,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ child: Column(
TopWidget( crossAxisAlignment: CrossAxisAlignment.start,
message, children: [
groupStatus: groupStatus, TopWidget(
onTapReply: onTapReply, message,
), groupStatus: groupStatus,
if (content != null) onTapReply: onTapReply,
Html(
textStyle: message.metadata?["big"] == true
? TextStyle(fontSize: 32)
: null,
content!
.replaceAllMapped(
RegExp(
"(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
caseSensitive: false,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
)
.replaceAll("\n", "<br class=\"fake-break\"/>"),
), ),
if (textMessage?.editedAt != null) if (content != null)
Text("(edited)", style: theme.textTheme.labelSmall), Html(
if (textMessage != null) textStyle: message.metadata?["big"] == true
LinkPreview( ? TextStyle(fontSize: 32)
text: textMessage.text, : null,
backgroundColor: isSentByMe content!
? colorScheme.inversePrimary .replaceAllMapped(
: colorScheme.surfaceContainerLow, RegExp(
outsidePadding: EdgeInsets.only(top: 4), "(<a\\b[^>]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)",
insidePadding: EdgeInsets.symmetric( caseSensitive: false,
vertical: 8, ),
horizontal: 16, (m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
)
.replaceAll("\n", "<br class=\"fake-break\"/>"),
), ),
linkPreviewData: message.metadata?["linkPreviewData"], if (textMessage?.editedAt != null)
onLinkPreviewDataFetched: (linkPreviewData) => updateMessage( Text("(edited)", style: theme.textTheme.labelSmall),
message, if (textMessage != null)
message.copyWith( LinkPreview(
metadata: { text: textMessage.text,
...(message.metadata ?? {}), backgroundColor: isSentByMe
"linkPreviewData": linkPreviewData, ? colorScheme.inversePrimary
}, : colorScheme.surfaceContainerLow,
outsidePadding: EdgeInsets.only(top: 4),
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.metadata?["linkPreviewData"],
onLinkPreviewDataFetched: (linkPreviewData) => updateMessage(
message,
message.copyWith(
metadata: {
...(message.metadata ?? {}),
"linkPreviewData": linkPreviewData,
},
),
), ),
), ),
), if (extra != null) extra!,
if (extra != null) extra!, ],
], ),
), ),
), ),
groupStatus,
); );
} }
} }