Files
org.parres.whitenoise/lib/widgets/chat_message_bubble.dart
T

227 lines
8.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData;
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/screens/start_chat_screen.dart';
import 'package:whitenoise/src/rust/api/markdown.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/bubble_grouping.dart' show leadingVariant;
import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/utils/encoding.dart' show hexFromNpub;
import 'package:whitenoise/widgets/chat_message_media.dart';
import 'package:whitenoise/widgets/chat_message_quote.dart';
import 'package:whitenoise/widgets/markdown_text.dart';
import 'package:whitenoise/widgets/media_modal.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_chat_status.dart';
import 'package:whitenoise/widgets/wn_message_bubble.dart';
class ChatMessageBubble extends StatelessWidget {
final ChatMessage message;
final List<HighlightSpan>? highlightSpans;
final bool isOwnMessage;
final String? currentUserPubkey;
final ChatMessageQuoteData? replyPreview;
final String? senderName;
final String? senderPictureUrl;
final bool showAvatar;
final bool showTail;
final bool isGroupChat;
final int? contentMaxLines;
final double bubbleWidthFactor;
final bool forceTightHeight;
final VoidCallback? onLongPress;
final void Function(String emoji)? onReaction;
final VoidCallback? onReplyTap;
final VoidCallback? onHorizontalDragEnd;
final VoidCallback? onRetry;
final String? Function(String hexPubkey)? mentionDisplayName;
final String? groupId;
const ChatMessageBubble({
super.key,
required this.message,
this.highlightSpans,
required this.isOwnMessage,
this.currentUserPubkey,
this.replyPreview,
this.senderName,
this.senderPictureUrl,
this.showAvatar = false,
this.showTail = true,
this.isGroupChat = false,
this.contentMaxLines,
this.bubbleWidthFactor = 0.8,
this.forceTightHeight = false,
this.onLongPress,
this.onReaction,
this.onReplyTap,
this.onHorizontalDragEnd,
this.onRetry,
this.mentionDisplayName,
this.groupId,
});
ChatStatusType? get _deliveryStatusType {
final status = message.deliveryStatus;
if (status == null) return null;
return switch (status) {
DeliveryStatus_Sending() => ChatStatusType.sending,
DeliveryStatus_Sent() => ChatStatusType.sent,
DeliveryStatus_Failed() => ChatStatusType.failed,
DeliveryStatus_Retried() => null,
};
}
void _showMediaModal(BuildContext context, int index) {
MediaModal.show(
context: context,
mediaFiles: message.mediaAttachments,
initialIndex: index,
senderName: senderName,
senderPictureUrl: senderPictureUrl,
senderPubkey: message.pubkey,
timestamp: message.createdAt,
);
}
static String _formatTime(DateTime datetime) {
final local = datetime.toLocal();
final h = local.hour.toString().padLeft(2, '0');
final m = local.minute.toString().padLeft(2, '0');
return '$h:$m';
}
Future<void> _handleLinkTap(BuildContext context, String url) async {
if (!isSafeMarkdownUrl(url)) return;
final uri = Uri.tryParse(url.trim());
if (uri == null) return;
final scheme = uri.scheme.toLowerCase();
if (scheme == DeepLinks.productionScheme || scheme == DeepLinks.stagingScheme) {
final target = DeepLinks.parse(uri);
if (target == null) {
if (context.mounted) await _showUnsupportedDeepLinkDialog(context);
return;
}
if (target.type == DeepLinkTargetType.user) {
final hex = hexFromNpub(uri.pathSegments.first);
if (hex != null && context.mounted) {
await StartChatScreen.show(context, userPubkey: hex);
}
return;
}
if (context.mounted) GoRouter.of(context).go(target.location);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _showUnsupportedDeepLinkDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
key: const Key('unsupported_deep_link_dialog'),
title: Text(dialogContext.l10n.unsupportedDeepLinkTitle),
content: Text(dialogContext.l10n.unsupportedDeepLinkMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(MaterialLocalizations.of(dialogContext).okButtonLabel),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (isOwnMessage && message.deliveryStatus is DeliveryStatus_Retried) {
return const SizedBox.shrink();
}
final avatarColor = AvatarColor.fromPubkey(message.pubkey);
final colorSet = avatarColor.toColorSet(context.colors);
final replyAuthorColor = isGroupChat && replyPreview != null && !replyPreview!.isNotFound
? AvatarColor.fromPubkey(replyPreview!.authorPubkey).toColorSet(context.colors).content
: null;
final showStatus = showTail || _deliveryStatusType == ChatStatusType.failed;
return WnMessageBubble(
key: Key('message_${message.id}'),
direction: isOwnMessage ? MessageDirection.outgoing : MessageDirection.incoming,
isDeleted: message.isDeleted,
deletedLabel: message.isDeleted
? (isOwnMessage ? context.l10n.youDeletedThisMessage : context.l10n.thisMessageWasDeleted)
: null,
showTail: showTail,
content: message.content.isNotEmpty ? message.content : null,
document: message.content.isNotEmpty ? message.contentTokens : null,
highlightSpans: highlightSpans,
onLinkTap: (url) => _handleLinkTap(context, url),
onNostrTap: (hrp, bech32) async {
if (hrp == MarkdownNostrHrp.npub) {
final hex = hexFromNpub(bech32);
if (hex == null || !context.mounted) return;
final gid = groupId;
if (gid != null) {
Routes.pushToGroupMember(context, gid, hex);
} else {
await StartChatScreen.show(context, userPubkey: hex);
}
return;
}
final uri = Uri.tryParse('nostr:$bech32');
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
},
mentionDisplayName: mentionDisplayName,
mediaContent: message.mediaAttachments.isNotEmpty
? ChatMessageMedia(
key: const Key('message_media'),
mediaFiles: message.mediaAttachments,
onMediaTap: (index) => _showMediaModal(context, index),
)
: null,
replyContent: replyPreview != null
? ChatMessageQuote(
data: replyPreview!,
currentUserPubkey: currentUserPubkey,
mentionDisplayName: mentionDisplayName,
onTap: onReplyTap,
authorColor: replyAuthorColor,
)
: null,
timestamp: showStatus ? _formatTime(message.createdAt) : null,
reactions: message.reactions.byEmoji,
currentUserPubkey: currentUserPubkey,
avatar: !isOwnMessage && showAvatar
? WnAvatar(
pictureUrl: senderPictureUrl,
displayName: senderName,
size: WnAvatarSize.xSmall,
color: avatarColor,
)
: null,
senderName: !isOwnMessage && showAvatar ? senderName : null,
senderNameColor: colorSet.content,
leadingVariant: leadingVariant(
isOwnMessage: isOwnMessage,
showTail: showTail,
isGroupChat: isGroupChat,
),
deliveryStatus: isOwnMessage ? _deliveryStatusType : null,
onLongPress: onLongPress,
onReaction: onReaction,
onHorizontalDragEnd: onHorizontalDragEnd,
onStatusTap: _deliveryStatusType == ChatStatusType.failed ? onRetry : null,
contentMaxLines: contentMaxLines,
bubbleWidthFactor: bubbleWidthFactor,
forceTightHeight: forceTightHeight,
);
}
}