💄 Optimize message flashing

This commit is contained in:
2025-09-23 20:27:18 +08:00
parent e68c5f4f92
commit f760b85186
2 changed files with 201 additions and 185 deletions

View File

@@ -25,7 +25,7 @@ class MessageContent extends StatelessWidget {
children: [ children: [
Icon( Icon(
Symbols.delete, Symbols.delete,
size: 14, size: 16,
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -34,6 +34,7 @@ class MessageContent extends StatelessWidget {
Text( Text(
item.content ?? 'Deleted a message', item.content ?? 'Deleted a message',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 13,
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -59,7 +60,7 @@ class MessageContent extends StatelessWidget {
children: [ children: [
Icon( Icon(
Symbols.edit, Symbols.edit,
size: 14, size: 16,
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
@@ -71,7 +72,7 @@ class MessageContent extends StatelessWidget {
newText: item.content ?? 'Edited a message', newText: item.content ?? 'Edited a message',
defaultTextStyle: Theme.of( defaultTextStyle: Theme.of(
context, context,
).textTheme.bodySmall!.copyWith( ).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
addedTextStyle: TextStyle( addedTextStyle: TextStyle(

View File

@@ -54,6 +54,8 @@ class MessageItem extends HookConsumerWidget {
required this.onJump, required this.onJump,
}); });
static const kFlashDuration = 300;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final remoteMessage = message.toRemoteMessage(); final remoteMessage = message.toRemoteMessage();
@@ -119,37 +121,92 @@ class MessageItem extends HookConsumerWidget {
); );
} }
final flashing = ref.watch(
flashingMessagesProvider.select((set) => set.contains(message.id)),
);
final isFlashing = useState(false);
final flashTimer = useState<Timer?>(null);
useEffect(() {
if (flashing) {
if (flashTimer.value != null) return null;
isFlashing.value = true;
flashTimer.value = Timer.periodic(
const Duration(milliseconds: kFlashDuration),
(timer) {
isFlashing.value = !isFlashing.value;
if (timer.tick >= 6) {
// 6 ticks: 1, 0, 1, 0, 1, 0
timer.cancel();
flashTimer.value = null;
isFlashing.value = false;
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.difference({message.id}));
}
},
);
} else {
flashTimer.value?.cancel();
flashTimer.value = null;
isFlashing.value = false;
}
return () {
flashTimer.value?.cancel();
};
}, [flashing]);
final flashColor =
isFlashing.value
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.8)
: Colors.transparent;
return InkWell( return InkWell(
onLongPress: showActionMenu, onLongPress: showActionMenu,
child: switch (settings.messageDisplayStyle) { onSecondaryTap: showActionMenu,
'irc' => MessageItemDisplayIRC( onTap: () {
message: message, // Jump to related message
isCurrentUser: isCurrentUser, if (['messages.update', 'messages.delete'].contains(message.type) &&
progress: progress, message.meta['message_id'] is String &&
showAvatar: showAvatar, message.meta['message_id'] != null) {
onJump: onJump, onJump(message.meta['message_id']);
translatedText: translatedText.value, }
translating: translating.value,
),
'column' => MessageItemDisplayDiscord(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
_ => MessageItemDisplayBubble(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
}, },
child: AnimatedContainer(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: kFlashDuration),
decoration: BoxDecoration(color: flashColor),
child: switch (settings.messageDisplayStyle) {
'irc' => MessageItemDisplayIRC(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
'column' => MessageItemDisplayDiscord(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
_ => MessageItemDisplayBubble(
message: message,
isCurrentUser: isCurrentUser,
progress: progress,
showAvatar: showAvatar,
onJump: onJump,
translatedText: translatedText.value,
translating: translating.value,
),
},
),
); );
} }
} }
@@ -286,54 +343,10 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
isCurrentUser isCurrentUser
? Theme.of(context).colorScheme.onPrimaryContainer ? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant; : Theme.of(context).colorScheme.onSurfaceVariant;
final containerColor =
isCurrentUser
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
final hasBackground = final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null; ref.watch(backgroundImageFileProvider).valueOrNull != null;
final flashing = ref.watch(
flashingMessagesProvider.select((set) => set.contains(message.id)),
);
final isFlashing = useState(false);
final flashTimer = useState<Timer?>(null);
useEffect(() {
if (flashing) {
if (flashTimer.value != null) return null;
isFlashing.value = true;
flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), (
timer,
) {
isFlashing.value = !isFlashing.value;
if (timer.tick >= 4) {
// 4 ticks: true, false, true, false
timer.cancel();
flashTimer.value = null;
isFlashing.value = false;
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.difference({message.id}));
}
});
} else {
flashTimer.value?.cancel();
flashTimer.value = null;
isFlashing.value = false;
}
return () {
flashTimer.value?.cancel();
};
}, [flashing]);
final flashColor =
isFlashing.value
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
: containerColor;
final remoteMessage = message.toRemoteMessage(); final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender; final sender = remoteMessage.sender;
@@ -364,109 +377,98 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible( Flexible(
child: AnimatedContainer( child: Column(
duration: const Duration(milliseconds: 200), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: flashColor, if (remoteMessage.repliedMessageId != null)
borderRadius: BorderRadius.circular(16), MessageQuoteWidget(
), message: message,
padding: const EdgeInsets.symmetric( textColor: textColor,
horizontal: 12, isReply: true,
vertical: 6, ).padding(vertical: 4),
), if (remoteMessage.forwardedMessageId != null)
child: Column( MessageQuoteWidget(
crossAxisAlignment: CrossAxisAlignment.start, message: message,
children: [ textColor: textColor,
if (remoteMessage.repliedMessageId != null) isReply: false,
MessageQuoteWidget( ).padding(vertical: 4),
message: message, if (MessageContent.hasContent(remoteMessage))
textColor: textColor, MessageContent(
isReply: true, item: remoteMessage,
).padding(vertical: 4), translatedText: translatedText,
if (remoteMessage.forwardedMessageId != null) ),
MessageQuoteWidget( if (remoteMessage.attachments.isNotEmpty)
message: message, LayoutBuilder(
textColor: textColor, builder: (context, constraints) {
isReply: false, return CloudFileList(
).padding(vertical: 4), files: remoteMessage.attachments,
if (MessageContent.hasContent(remoteMessage)) maxWidth: constraints.maxWidth,
MessageContent( padding: EdgeInsets.symmetric(vertical: 4),
item: remoteMessage, );
translatedText: translatedText, },
), ),
if (remoteMessage.attachments.isNotEmpty) if (remoteMessage.meta['embeds'] != null)
LayoutBuilder( ...((remoteMessage.meta['embeds'] as List<dynamic>)
builder: (context, constraints) { .map((embed) => convertMapKeysToSnakeCase(embed))
return CloudFileList( .where((embed) => embed['type'] == 'link')
files: remoteMessage.attachments, .map((embed) => SnScrappedLink.fromJson(embed))
maxWidth: constraints.maxWidth, .map(
padding: EdgeInsets.symmetric(vertical: 4), (link) => LayoutBuilder(
); builder: (context, constraints) {
}, return EmbedLinkWidget(
), link: link,
if (remoteMessage.meta['embeds'] != null) maxWidth: math.min(
...((remoteMessage.meta['embeds'] as List<dynamic>) constraints.maxWidth,
.map((embed) => convertMapKeysToSnakeCase(embed)) 480,
.where((embed) => embed['type'] == 'link')
.map((embed) => SnScrappedLink.fromJson(embed))
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(
constraints.maxWidth,
480,
),
margin: const EdgeInsets.symmetric(
vertical: 4,
),
);
},
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ?? false))
const Gap(0),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
), ),
const Gap(4), margin: const EdgeInsets.symmetric(
LinearProgressIndicator( vertical: 4,
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
), ),
], );
), },
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ?? false))
const Gap(0), const Gap(0),
], for (var entry in progress!.entries)
), Column(
], crossAxisAlignment: CrossAxisAlignment.start,
), children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(0),
],
),
],
), ),
), ),
MessageIndicators( MessageIndicators(
@@ -510,25 +512,38 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
final sender = remoteMessage.sender; final sender = remoteMessage.sender;
final textColor = Theme.of(context).colorScheme.onSurfaceVariant; final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
final isMultiline =
message.type == 'text' ||
message.repliedMessageId != null ||
message.forwardedMessageId != null;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [ children: [
Text( Text(
DateFormat('HH:mm').format(message.createdAt), DateFormat('HH:mm').format(message.createdAt),
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12), style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
).padding(top: 2), ).padding(top: isMultiline ? 2 : 0),
AccountPfcGestureDetector( AccountPfcGestureDetector(
uname: sender.account.name, uname: sender.account.name,
child: ProfilePictureWidget( child: Row(
file: sender.account.profile.picture, crossAxisAlignment: CrossAxisAlignment.center,
radius: 8, children: [
).padding(horizontal: 6, top: 2), ProfilePictureWidget(
), file: sender.account.profile.picture,
Text( radius: 8,
sender.account.nick, ).padding(horizontal: 6, top: isMultiline ? 2 : 0),
style: TextStyle(color: Theme.of(context).colorScheme.primary), Text(
sender.account.nick,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
), ),
const Gap(8), const Gap(8),
Expanded( Expanded(