587 lines
21 KiB
Dart
587 lines
21 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:gap/gap.dart';
|
|
import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData;
|
|
import 'package:whitenoise/hooks/use_share_message.dart';
|
|
import 'package:whitenoise/l10n/l10n.dart';
|
|
import 'package:whitenoise/src/rust/api/media_files.dart' show MediaFile;
|
|
import 'package:whitenoise/src/rust/api/messages.dart' show ChatMessage;
|
|
import 'package:whitenoise/theme.dart';
|
|
import 'package:whitenoise/utils/bubble_grouping.dart' show shouldShowAvatar;
|
|
import 'package:whitenoise/widgets/chat_message_bubble.dart';
|
|
import 'package:whitenoise/widgets/wn_button.dart';
|
|
import 'package:whitenoise/widgets/wn_emoji_picker.dart';
|
|
import 'package:whitenoise/widgets/wn_icon.dart';
|
|
import 'package:whitenoise/widgets/wn_slate.dart';
|
|
import 'package:whitenoise/widgets/wn_system_notice.dart';
|
|
|
|
const _modalViewportVerticalInset = 96.0;
|
|
const _modalPreviewTopPadding = 10.0;
|
|
const _modalPreviewBottomPadding = 12.0;
|
|
const _modalSenderRowHeight = 20.0;
|
|
const _modalSenderGap = 8.0;
|
|
const _modalReplyRowHeight = 56.0;
|
|
const _modalMediaHeight = 96.0;
|
|
const _modalReactionsHeight = 36.0;
|
|
const _modalSectionSpacing = 16.0;
|
|
const _modalButtonSpacing = 8.0;
|
|
const _modalContentHorizontalPadding = 14.0;
|
|
const _modalContentVerticalPadding = 14.0;
|
|
const _modalPreviewSafetyReserve = 32.0;
|
|
const _modalMinPreviewHeight = 1.0;
|
|
const _emojiPickerReservedHeight = 320.0;
|
|
const _modalToPickerGap = 8.0;
|
|
|
|
double _modalMessageLineHeight(BuildContext context, Color textColor) {
|
|
final style = context.typographyScaled.medium16Compact.copyWith(color: textColor);
|
|
final tp = TextPainter(
|
|
text: TextSpan(text: ' ', style: style),
|
|
textDirection: TextDirection.ltr,
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
);
|
|
try {
|
|
tp.layout();
|
|
return tp.preferredLineHeight;
|
|
} finally {
|
|
tp.dispose();
|
|
}
|
|
}
|
|
|
|
int _modalPreviewContentMaxLines(
|
|
BuildContext context, {
|
|
required double slotHeight,
|
|
required bool isOwnMessage,
|
|
required bool showAvatar,
|
|
required String? senderName,
|
|
required bool hasReplyPreview,
|
|
required bool hasBubbleMedia,
|
|
required bool hasBubbleReactions,
|
|
}) {
|
|
var textBudget =
|
|
slotHeight -
|
|
_modalPreviewTopPadding.h -
|
|
_modalPreviewBottomPadding.h -
|
|
_modalPreviewSafetyReserve.h;
|
|
final hasSender = !isOwnMessage && showAvatar && senderName != null && senderName.isNotEmpty;
|
|
if (hasSender) {
|
|
textBudget -= _modalSenderRowHeight.h + _modalSenderGap.h;
|
|
}
|
|
if (hasReplyPreview) {
|
|
textBudget -= _modalSenderGap.h + _modalReplyRowHeight.h;
|
|
}
|
|
if (hasBubbleMedia) {
|
|
textBudget -= _modalSenderGap.h + _modalMediaHeight.h;
|
|
}
|
|
if (hasBubbleReactions) {
|
|
textBudget -= _modalSenderGap.h + _modalReactionsHeight.h;
|
|
}
|
|
final colors = context.colors;
|
|
final textColor = isOwnMessage ? colors.fillContentPrimary : colors.backgroundContentPrimary;
|
|
final lh = math.max(_modalMessageLineHeight(context, textColor), 1.0);
|
|
return math.max(1, (textBudget / lh).floor());
|
|
}
|
|
|
|
class MessageActionsScreen extends HookWidget {
|
|
const MessageActionsScreen({
|
|
super.key,
|
|
required this.message,
|
|
required this.pubkey,
|
|
required this.onAddReaction,
|
|
required this.onRemoveReaction,
|
|
this.onDelete,
|
|
this.onReply,
|
|
this.senderName,
|
|
this.senderPictureUrl,
|
|
this.isGroupChat = false,
|
|
this.isOffline = false,
|
|
this.getChatMessageQuote,
|
|
this.mentionDisplayName,
|
|
});
|
|
|
|
final ChatMessage message;
|
|
final String pubkey;
|
|
final Future<void> Function(String emoji) onAddReaction;
|
|
final Future<void> Function(String reactionId) onRemoveReaction;
|
|
final Future<void> Function()? onDelete;
|
|
final void Function(ChatMessage message)? onReply;
|
|
final String? senderName;
|
|
final String? senderPictureUrl;
|
|
final bool isGroupChat;
|
|
final bool isOffline;
|
|
final ChatMessageQuoteData? Function(String? replyId)? getChatMessageQuote;
|
|
final String? Function(String hexPubkey)? mentionDisplayName;
|
|
|
|
static Future<void> show(
|
|
BuildContext context, {
|
|
required ChatMessage message,
|
|
required String pubkey,
|
|
required Future<void> Function(String emoji) onAddReaction,
|
|
required Future<void> Function(String reactionId) onRemoveReaction,
|
|
Future<void> Function()? onDelete,
|
|
void Function(ChatMessage message)? onReply,
|
|
String? senderName,
|
|
String? senderPictureUrl,
|
|
bool isGroupChat = false,
|
|
bool isOffline = false,
|
|
ChatMessageQuoteData? Function(String? replyId)? getChatMessageQuote,
|
|
String? Function(String hexPubkey)? mentionDisplayName,
|
|
}) {
|
|
final colors = context.colors;
|
|
|
|
return Navigator.of(context).push(
|
|
PageRouteBuilder(
|
|
opaque: false,
|
|
barrierDismissible: true,
|
|
barrierColor: colors.backgroundPrimary.withValues(alpha: 0.8),
|
|
pageBuilder: (menuContext, _, _) {
|
|
return MessageActionsScreen(
|
|
message: message,
|
|
pubkey: pubkey,
|
|
onAddReaction: onAddReaction,
|
|
onRemoveReaction: onRemoveReaction,
|
|
onDelete: onDelete,
|
|
onReply: onReply,
|
|
senderName: senderName,
|
|
senderPictureUrl: senderPictureUrl,
|
|
isGroupChat: isGroupChat,
|
|
isOffline: isOffline,
|
|
getChatMessageQuote: getChatMessageQuote,
|
|
mentionDisplayName: mentionDisplayName,
|
|
);
|
|
},
|
|
transitionsBuilder: (_, animation, _, child) {
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: child,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final showEmojiPicker = useState(false);
|
|
final noticeMessage = useState<String?>(null);
|
|
|
|
void showNotice(String message) {
|
|
noticeMessage.value = message;
|
|
}
|
|
|
|
void dismissNotice() {
|
|
noticeMessage.value = null;
|
|
}
|
|
|
|
final isOwnMessage = message.pubkey == pubkey;
|
|
final userReactionIds = Map.fromEntries(
|
|
message.reactions.userReactions
|
|
.where((r) => r.user == pubkey)
|
|
.map((r) => MapEntry(r.emoji, r.reactionId)),
|
|
);
|
|
final selectedEmojis = userReactionIds.keys.toSet();
|
|
|
|
final cachedMedia = useMemoized(
|
|
() => _cachedAttachments(message.mediaAttachments),
|
|
[
|
|
message.id,
|
|
message.mediaAttachments.map((m) => m.filePath).join(''),
|
|
],
|
|
);
|
|
final cachedPaths = cachedMedia.map((m) => m.filePath).toList(growable: false);
|
|
final hasContent = !message.isDeleted && message.content.trim().isNotEmpty;
|
|
final shareMessage = useShareMessage(
|
|
text: hasContent ? message.content : null,
|
|
filePaths: cachedPaths,
|
|
onError: (_) => showNotice(l10n.shareError),
|
|
);
|
|
final shareFn = shareMessage.share;
|
|
final shareCallback = shareFn == null
|
|
? null
|
|
: () async {
|
|
final renderBox = context.findRenderObject() as RenderBox?;
|
|
final origin = renderBox != null && renderBox.hasSize
|
|
? renderBox.localToGlobal(Offset.zero) & renderBox.size
|
|
: null;
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
await shareFn(sharePositionOrigin: origin);
|
|
};
|
|
|
|
Future<void> handleDelete() async {
|
|
try {
|
|
await onDelete?.call();
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showNotice(context.l10n.failedToDeleteMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> handleReaction(String emoji) async {
|
|
final reactionId = userReactionIds[emoji];
|
|
try {
|
|
if (reactionId != null) {
|
|
await onRemoveReaction(reactionId);
|
|
} else {
|
|
await onAddReaction(emoji);
|
|
}
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showNotice(
|
|
reactionId != null
|
|
? context.l10n.failedToRemoveReaction
|
|
: context.l10n.failedToSendReaction,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return SafeArea(
|
|
child: Column(
|
|
children: [
|
|
if (noticeMessage.value != null)
|
|
WnSystemNotice(
|
|
key: ValueKey(noticeMessage.value),
|
|
title: noticeMessage.value!,
|
|
type: WnSystemNoticeType.error,
|
|
onDismiss: dismissNotice,
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16.h),
|
|
child: Column(
|
|
mainAxisAlignment: showEmojiPicker.value
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.center,
|
|
children: [
|
|
MessageActionsModal(
|
|
message: message,
|
|
isOwnMessage: isOwnMessage,
|
|
currentUserPubkey: pubkey,
|
|
onDelete: (isOwnMessage && onDelete != null && !isOffline)
|
|
? handleDelete
|
|
: null,
|
|
onReaction: isOffline ? null : handleReaction,
|
|
onEmojiPicker: isOffline
|
|
? null
|
|
: () => showEmojiPicker.value = !showEmojiPicker.value,
|
|
selectedEmojis: selectedEmojis,
|
|
onReply: onReply != null
|
|
? () {
|
|
Navigator.of(context).pop();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
onReply!(message);
|
|
});
|
|
}
|
|
: null,
|
|
onShare: shareCallback,
|
|
senderName: senderName,
|
|
senderPictureUrl: senderPictureUrl,
|
|
isGroupChat: isGroupChat,
|
|
getChatMessageQuote: getChatMessageQuote,
|
|
mentionDisplayName: mentionDisplayName,
|
|
bottomInset: showEmojiPicker.value
|
|
? _emojiPickerReservedHeight.h + _modalToPickerGap.h
|
|
: 0,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (showEmojiPicker.value)
|
|
WnEmojiPicker(
|
|
onClose: () => showEmojiPicker.value = false,
|
|
onEmojiSelected: handleReaction,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MessageActionsModal extends StatelessWidget {
|
|
const MessageActionsModal({
|
|
super.key,
|
|
required this.message,
|
|
required this.isOwnMessage,
|
|
required this.currentUserPubkey,
|
|
this.onReaction,
|
|
this.onEmojiPicker,
|
|
this.onDelete,
|
|
this.selectedEmojis = const {},
|
|
this.onReply,
|
|
this.onShare,
|
|
this.senderName,
|
|
this.senderPictureUrl,
|
|
this.isGroupChat = false,
|
|
this.getChatMessageQuote,
|
|
this.bottomInset = 0,
|
|
this.mentionDisplayName,
|
|
});
|
|
|
|
final ChatMessage message;
|
|
final bool isOwnMessage;
|
|
final void Function(String emoji)? onReaction;
|
|
final VoidCallback? onEmojiPicker;
|
|
final String currentUserPubkey;
|
|
final VoidCallback? onDelete;
|
|
final Set<String> selectedEmojis;
|
|
final VoidCallback? onReply;
|
|
final VoidCallback? onShare;
|
|
final String? senderName;
|
|
final String? senderPictureUrl;
|
|
final bool isGroupChat;
|
|
final ChatMessageQuoteData? Function(String? replyId)? getChatMessageQuote;
|
|
final double bottomInset;
|
|
final String? Function(String hexPubkey)? mentionDisplayName;
|
|
|
|
static const List<String> reactions = [
|
|
'❤',
|
|
'😀',
|
|
'👍',
|
|
'👎',
|
|
'🤣',
|
|
'🔥',
|
|
'🦫',
|
|
];
|
|
|
|
double _controlsEstimatedHeight() {
|
|
var height = _modalSectionSpacing.h + 52.h;
|
|
if (onReaction != null) {
|
|
height += 40.h + _modalSectionSpacing.h;
|
|
}
|
|
if (onReply != null) {
|
|
height += _modalButtonSpacing.h + 52.h;
|
|
}
|
|
if (onShare != null) {
|
|
height += _modalButtonSpacing.h + 52.h;
|
|
}
|
|
if (onDelete != null) {
|
|
height += _modalButtonSpacing.h + 52.h;
|
|
}
|
|
return height;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.colors;
|
|
final replyPreview = message.isReply ? getChatMessageQuote?.call(message.replyToId) : null;
|
|
final maxSlateHeight = math.max(
|
|
0.0,
|
|
MediaQuery.sizeOf(context).height - (2 * _modalViewportVerticalInset.h) - bottomInset,
|
|
);
|
|
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxSlateHeight),
|
|
child: LayoutBuilder(
|
|
builder: (context, slateConstraints) {
|
|
return WnSlate(
|
|
shrinkWrapContent: true,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: slateConstraints.maxWidth,
|
|
maxHeight: math.max(0.0, slateConstraints.maxHeight - 2),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
_modalContentHorizontalPadding.w,
|
|
_modalContentVerticalPadding.h,
|
|
_modalContentHorizontalPadding.w,
|
|
_modalContentVerticalPadding.h,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, innerConstraints) {
|
|
final showAvatar = shouldShowAvatar(
|
|
current: message,
|
|
next: null,
|
|
isOwnMessage: isOwnMessage,
|
|
isGroupChat: isGroupChat,
|
|
);
|
|
final previewMaxHeight = math.max(
|
|
0.0,
|
|
innerConstraints.maxHeight - _controlsEstimatedHeight(),
|
|
);
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (previewMaxHeight >= _modalMinPreviewHeight.h)
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: previewMaxHeight),
|
|
child: LayoutBuilder(
|
|
builder: (context, slotConstraints) {
|
|
final maxLines = _modalPreviewContentMaxLines(
|
|
context,
|
|
slotHeight: slotConstraints.maxHeight,
|
|
isOwnMessage: isOwnMessage,
|
|
showAvatar: showAvatar,
|
|
senderName: senderName,
|
|
hasReplyPreview: replyPreview != null,
|
|
hasBubbleMedia: message.mediaAttachments.isNotEmpty,
|
|
hasBubbleReactions: message.reactions.byEmoji.isNotEmpty,
|
|
);
|
|
final shouldConstrainPreviewLines =
|
|
message.content.runes.length > 32 ||
|
|
message.content.contains('\n') ||
|
|
replyPreview != null ||
|
|
message.mediaAttachments.isNotEmpty ||
|
|
message.reactions.byEmoji.isNotEmpty;
|
|
return UnconstrainedBox(
|
|
constrainedAxis: Axis.horizontal,
|
|
clipBehavior: Clip.hardEdge,
|
|
alignment: isOwnMessage ? Alignment.topRight : Alignment.topLeft,
|
|
child: ChatMessageBubble(
|
|
message: message,
|
|
isOwnMessage: isOwnMessage,
|
|
currentUserPubkey: currentUserPubkey,
|
|
showAvatar: showAvatar,
|
|
senderName: senderName,
|
|
senderPictureUrl: senderPictureUrl,
|
|
isGroupChat: isGroupChat,
|
|
replyPreview: replyPreview,
|
|
contentMaxLines: shouldConstrainPreviewLines ? maxLines : null,
|
|
bubbleWidthFactor: 0.865,
|
|
forceTightHeight: true,
|
|
mentionDisplayName: mentionDisplayName,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
if (onReaction != null) ...[
|
|
SizedBox(height: _modalSectionSpacing.h),
|
|
Row(
|
|
children: [
|
|
...reactions.map(
|
|
(emoji) => Expanded(
|
|
child: _ReactionButton(
|
|
key: Key('reaction_$emoji'),
|
|
colors: colors,
|
|
emoji: emoji,
|
|
isSelected: selectedEmojis.contains(emoji),
|
|
onTap: () => onReaction!(emoji),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
key: const Key('emoji_picker_button'),
|
|
onTap: onEmojiPicker,
|
|
child: Center(
|
|
child: WnIcon(
|
|
WnIcons.addEmoji,
|
|
color: colors.backgroundContentPrimary,
|
|
size: 20.sp,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
SizedBox(height: _modalSectionSpacing.h),
|
|
if (onReply != null) ...[
|
|
WnButton(
|
|
key: const Key('reply_button'),
|
|
text: context.l10n.reply,
|
|
type: WnButtonType.outline,
|
|
size: WnButtonSize.medium,
|
|
trailingIcon: WnIcons.reply,
|
|
onPressed: onReply,
|
|
),
|
|
Gap(_modalButtonSpacing.h),
|
|
],
|
|
WnButton(
|
|
key: const Key('copy_button'),
|
|
text: context.l10n.copyMessage,
|
|
type: WnButtonType.outline,
|
|
size: WnButtonSize.medium,
|
|
trailingIcon: WnIcons.copy,
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: message.content));
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
if (onShare != null) ...[
|
|
Gap(_modalButtonSpacing.h),
|
|
WnButton(
|
|
key: const Key('share_button'),
|
|
text: context.l10n.share,
|
|
type: WnButtonType.outline,
|
|
size: WnButtonSize.medium,
|
|
trailingIcon: WnIcons.share,
|
|
onPressed: onShare,
|
|
),
|
|
],
|
|
if (onDelete != null) ...[
|
|
Gap(_modalButtonSpacing.h),
|
|
WnButton(
|
|
key: const Key('delete_button'),
|
|
text: context.l10n.delete,
|
|
type: WnButtonType.destructive,
|
|
size: WnButtonSize.medium,
|
|
trailingIcon: WnIcons.trashCan,
|
|
onPressed: onDelete,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReactionButton extends StatelessWidget {
|
|
const _ReactionButton({
|
|
super.key,
|
|
required this.colors,
|
|
required this.emoji,
|
|
required this.onTap,
|
|
this.isSelected = false,
|
|
});
|
|
|
|
final SemanticColors colors;
|
|
final String emoji;
|
|
final VoidCallback onTap;
|
|
final bool isSelected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Center(
|
|
child: Container(
|
|
padding: EdgeInsets.all(8.w),
|
|
decoration: isSelected
|
|
? BoxDecoration(
|
|
color: colors.fillTertiaryActive,
|
|
borderRadius: BorderRadius.circular(8.r),
|
|
)
|
|
: null,
|
|
child: Text(
|
|
emoji,
|
|
style: context.typographyScaled.medium20,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<MediaFile> _cachedAttachments(List<MediaFile> attachments) {
|
|
final cachedPaths = filterExistingFiles(attachments.map((m) => m.filePath)).toSet();
|
|
return attachments.where((m) => cachedPaths.contains(m.filePath)).toList(growable: false);
|
|
}
|