refactor: New message context menu

This commit is contained in:
Christian Kußowski 2025-06-16 18:11:49 +02:00
commit 033feed6b1
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
10 changed files with 857 additions and 854 deletions

View file

@ -24,6 +24,8 @@ abstract class AppConfig {
static String _privacyUrl = static String _privacyUrl =
'https://github.com/krille-chan/fluffychat/blob/main/PRIVACY.md'; 'https://github.com/krille-chan/fluffychat/blob/main/PRIVACY.md';
static const Set<String> defaultReactions = {'👍', '❤️', '😊'};
static String get privacyUrl => _privacyUrl; static String get privacyUrl => _privacyUrl;
static const String website = 'https://fluffychat.im'; static const String website = 'https://fluffychat.im';
static const String enablePushTutorial = static const String enablePushTutorial =

View file

@ -3239,5 +3239,6 @@
"pleaseWaitUntilInvited": "Please wait now, until someone from the room invites you.", "pleaseWaitUntilInvited": "Please wait now, until someone from the room invites you.",
"commandHint_logout": "Logout your current device", "commandHint_logout": "Logout your current device",
"commandHint_logoutall": "Logout all active devices", "commandHint_logoutall": "Logout all active devices",
"displayNavigationRail": "Show navigation rail on mobile" "displayNavigationRail": "Show navigation rail on mobile",
"customReaction": "Custom reaction"
} }

View file

@ -192,8 +192,6 @@ class ChatController extends State<ChatPageWithRoom>
context.go('/rooms'); context.go('/rooms');
} }
EmojiPickerType emojiPickerType = EmojiPickerType.keyboard;
void requestHistory([_]) async { void requestHistory([_]) async {
Logs().v('Requesting history...'); Logs().v('Requesting history...');
await timeline?.requestHistory(historyCount: _loadHistoryCount); await timeline?.requestHistory(historyCount: _loadHistoryCount);
@ -708,13 +706,11 @@ class ChatController extends State<ChatPageWithRoom>
} else { } else {
inputFocus.unfocus(); inputFocus.unfocus();
} }
emojiPickerType = EmojiPickerType.keyboard;
setState(() => showEmojiPicker = !showEmojiPicker); setState(() => showEmojiPicker = !showEmojiPicker);
} }
void _inputFocusListener() { void _inputFocusListener() {
if (showEmojiPicker && inputFocus.hasFocus) { if (showEmojiPicker && inputFocus.hasFocus) {
emojiPickerType = EmojiPickerType.keyboard;
setState(() => showEmojiPicker = false); setState(() => showEmojiPicker = false);
} }
} }
@ -895,16 +891,6 @@ class ChatController extends State<ChatPageWithRoom>
return true; return true;
} }
bool get canEditSelectedEvents {
if (isArchived ||
selectedEvents.length != 1 ||
!selectedEvents.first.status.isSent) {
return false;
}
return currentRoomBundle
.any((cl) => selectedEvents.first.senderId == cl!.userID);
}
void forwardEventsAction() async { void forwardEventsAction() async {
if (selectedEvents.isEmpty) return; if (selectedEvents.isEmpty) return;
await showScaffoldDialog( await showScaffoldDialog(
@ -998,27 +984,8 @@ class ChatController extends State<ChatPageWithRoom>
} }
void onEmojiSelected(_, Emoji? emoji) { void onEmojiSelected(_, Emoji? emoji) {
switch (emojiPickerType) {
case EmojiPickerType.reaction:
senEmojiReaction(emoji);
break;
case EmojiPickerType.keyboard:
typeEmoji(emoji); typeEmoji(emoji);
onInputBarChanged(sendController.text); onInputBarChanged(sendController.text);
break;
}
}
void senEmojiReaction(Emoji? emoji) {
setState(() => showEmojiPicker = false);
if (emoji == null) return;
// make sure we don't send the same emoji twice
if (_allReactionEvents.any(
(e) => e.content.tryGetMap('m.relates_to')?['key'] == emoji.emoji,
)) {
return;
}
return sendEmojiAction(emoji.emoji);
} }
void typeEmoji(Emoji? emoji) { void typeEmoji(Emoji? emoji) {
@ -1037,38 +1004,12 @@ class ChatController extends State<ChatPageWithRoom>
); );
} }
late Iterable<Event> _allReactionEvents;
void emojiPickerBackspace() { void emojiPickerBackspace() {
switch (emojiPickerType) {
case EmojiPickerType.reaction:
setState(() => showEmojiPicker = false);
break;
case EmojiPickerType.keyboard:
sendController sendController
..text = sendController.text.characters.skipLast(1).toString() ..text = sendController.text.characters.skipLast(1).toString()
..selection = TextSelection.fromPosition( ..selection = TextSelection.fromPosition(
TextPosition(offset: sendController.text.length), TextPosition(offset: sendController.text.length),
); );
break;
}
}
void pickEmojiReactionAction(Iterable<Event> allReactionEvents) async {
_allReactionEvents = allReactionEvents;
emojiPickerType = EmojiPickerType.reaction;
setState(() => showEmojiPicker = true);
}
void sendEmojiAction(String? emoji) async {
final events = List<Event>.from(selectedEvents);
setState(() => selectedEvents.clear());
for (final event in events) {
await room.sendReaction(
event.eventId,
emoji!,
);
}
} }
void clearSelectedEvents() => setState(() { void clearSelectedEvents() => setState(() {
@ -1391,5 +1332,3 @@ class ChatController extends State<ChatPageWithRoom>
); );
} }
} }
enum EmojiPickerType { reaction, keyboard }

View file

@ -141,6 +141,10 @@ class ChatEventList extends StatelessWidget {
longPressSelect: controller.selectedEvents.isNotEmpty, longPressSelect: controller.selectedEvents.isNotEmpty,
selected: controller.selectedEvents selected: controller.selectedEvents
.any((e) => e.eventId == event.eventId), .any((e) => e.eventId == event.eventId),
singleSelected:
controller.selectedEvents.singleOrNull?.eventId ==
event.eventId,
onEdit: () => controller.editSelectedEventAction(),
timeline: timeline, timeline: timeline,
displayReadMarker: displayReadMarker:
i > 0 && controller.readMarkerEventId == event.eventId, i > 0 && controller.readMarkerEventId == event.eventId,

View file

@ -21,10 +21,6 @@ class ChatInputRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
if (controller.showEmojiPicker &&
controller.emojiPickerType == EmojiPickerType.reaction) {
return const SizedBox.shrink();
}
const height = 48.0; const height = 48.0;
if (!controller.room.otherPartyCanReceiveMessages) { if (!controller.room.otherPartyCanReceiveMessages) {
@ -43,71 +39,7 @@ class ChatInputRow extends StatelessWidget {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: controller.selectMode
? <Widget>[
if (controller.selectedEvents
.every((event) => event.status == EventStatus.error))
SizedBox(
height: height,
child: TextButton(
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
onPressed: controller.deleteErrorEventsAction,
child: Row(
children: <Widget>[ children: <Widget>[
const Icon(Icons.delete),
Text(L10n.of(context).delete),
],
),
),
)
else
SizedBox(
height: height,
child: TextButton(
onPressed: controller.forwardEventsAction,
child: Row(
children: <Widget>[
const Icon(Icons.keyboard_arrow_left_outlined),
Text(L10n.of(context).forward),
],
),
),
),
controller.selectedEvents.length == 1
? controller.selectedEvents.first
.getDisplayEvent(controller.timeline!)
.status
.isSent
? SizedBox(
height: height,
child: TextButton(
onPressed: controller.replyAction,
child: Row(
children: <Widget>[
Text(L10n.of(context).reply),
const Icon(Icons.keyboard_arrow_right),
],
),
),
)
: SizedBox(
height: height,
child: TextButton(
onPressed: controller.sendAgainAction,
child: Row(
children: <Widget>[
Text(L10n.of(context).tryToSendAgain),
const SizedBox(width: 4),
const Icon(Icons.send_outlined, size: 16),
],
),
),
)
: const SizedBox.shrink(),
]
: <Widget>[
const SizedBox(width: 4), const SizedBox(width: 4),
AnimatedContainer( AnimatedContainer(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
@ -119,18 +51,17 @@ class ChatInputRow extends StatelessWidget {
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: PopupMenuButton<String>( child: PopupMenuButton<String>(
useRootNavigator: true, useRootNavigator: true,
enabled: !controller.selectMode,
icon: const Icon(Icons.add_circle_outline), icon: const Icon(Icons.add_circle_outline),
iconColor: theme.colorScheme.onPrimaryContainer, iconColor: theme.colorScheme.onPrimaryContainer,
onSelected: controller.onAddPopupMenuButtonSelected, onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) => itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
<PopupMenuEntry<String>>[
if (PlatformInfos.isMobile) if (PlatformInfos.isMobile)
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'location', value: 'location',
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor: theme.colorScheme.onPrimaryContainer,
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer, foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.gps_fixed_outlined), child: const Icon(Icons.gps_fixed_outlined),
), ),
@ -187,6 +118,7 @@ class ChatInputRow extends StatelessWidget {
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: PopupMenuButton( child: PopupMenuButton(
enabled: !controller.selectMode,
useRootNavigator: true, useRootNavigator: true,
icon: const Icon(Icons.camera_alt_outlined), icon: const Icon(Icons.camera_alt_outlined),
onSelected: controller.onAddPopupMenuButtonSelected, onSelected: controller.onAddPopupMenuButtonSelected,
@ -196,8 +128,7 @@ class ChatInputRow extends StatelessWidget {
value: 'camera-video', value: 'camera-video',
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor: theme.colorScheme.onPrimaryContainer,
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer, foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.videocam_outlined), child: const Icon(Icons.videocam_outlined),
), ),
@ -209,8 +140,7 @@ class ChatInputRow extends StatelessWidget {
value: 'camera', value: 'camera',
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor: theme.colorScheme.onPrimaryContainer,
theme.colorScheme.onPrimaryContainer,
foregroundColor: theme.colorScheme.primaryContainer, foregroundColor: theme.colorScheme.primaryContainer,
child: const Icon(Icons.camera_alt_outlined), child: const Icon(Icons.camera_alt_outlined),
), ),
@ -249,7 +179,8 @@ class ChatInputRow extends StatelessWidget {
key: ValueKey(controller.showEmojiPicker), key: ValueKey(controller.showEmojiPicker),
), ),
), ),
onPressed: controller.emojiPickerAction, onPressed:
controller.selectMode ? null : controller.emojiPickerAction,
), ),
), ),
if (Matrix.of(context).isMultiAccount && if (Matrix.of(context).isMultiAccount &&
@ -267,6 +198,7 @@ class ChatInputRow extends StatelessWidget {
child: InputBar( child: InputBar(
room: controller.room, room: controller.room,
minLines: 1, minLines: 1,
readOnly: controller.selectMode,
maxLines: 8, maxLines: 8,
autofocus: !PlatformInfos.isMobile, autofocus: !PlatformInfos.isMobile,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
@ -295,7 +227,9 @@ class ChatInputRow extends StatelessWidget {
), ),
), ),
), ),
Container( Opacity(
opacity: controller.selectMode ? 0.66 : 1,
child: Container(
height: height, height: height,
width: height, width: height,
alignment: Alignment.center, alignment: Alignment.center,
@ -303,7 +237,9 @@ class ChatInputRow extends StatelessWidget {
controller.sendController.text.isEmpty controller.sendController.text.isEmpty
? FloatingActionButton.small( ? FloatingActionButton.small(
tooltip: L10n.of(context).voiceMessage, tooltip: L10n.of(context).voiceMessage,
onPressed: controller.voiceMessageAction, onPressed: controller.selectMode
? null
: controller.voiceMessageAction,
elevation: 0, elevation: 0,
heroTag: null, heroTag: null,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -315,7 +251,7 @@ class ChatInputRow extends StatelessWidget {
) )
: FloatingActionButton.small( : FloatingActionButton.small(
tooltip: L10n.of(context).send, tooltip: L10n.of(context).send,
onPressed: controller.send, onPressed: controller.selectMode ? null : controller.send,
elevation: 0, elevation: 0,
heroTag: null, heroTag: null,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -326,6 +262,7 @@ class ChatInputRow extends StatelessWidget {
child: const Icon(Icons.send_outlined), child: const Icon(Icons.send_outlined),
), ),
), ),
),
], ],
); );
} }

View file

@ -15,7 +15,6 @@ import 'package:fluffychat/pages/chat/chat_app_bar_title.dart';
import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/encryption_button.dart'; import 'package:fluffychat/pages/chat/encryption_button.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart';
import 'package:fluffychat/pages/chat/reactions_picker.dart';
import 'package:fluffychat/pages/chat/reply_display.dart'; import 'package:fluffychat/pages/chat/reply_display.dart';
import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
@ -38,12 +37,6 @@ class ChatView extends StatelessWidget {
List<Widget> _appBarActions(BuildContext context) { List<Widget> _appBarActions(BuildContext context) {
if (controller.selectMode) { if (controller.selectMode) {
return [ return [
if (controller.canEditSelectedEvents)
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: L10n.of(context).edit,
onPressed: controller.editSelectedEventAction,
),
IconButton( IconButton(
icon: const Icon(Icons.copy_outlined), icon: const Icon(Icons.copy_outlined),
tooltip: L10n.of(context).copy, tooltip: L10n.of(context).copy,
@ -353,7 +346,6 @@ class ChatView extends StatelessWidget {
: Column( : Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ReactionsPicker(controller),
ReplyDisplay(controller), ReplyDisplay(controller),
ChatInputRow(controller), ChatInputRow(controller),
ChatEmojiPicker(controller), ChatEmojiPicker(controller),

View file

@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:swipe_to_action/swipe_to_action.dart'; import 'package:swipe_to_action/swipe_to_action.dart';
@ -31,8 +32,10 @@ class Message extends StatelessWidget {
final void Function(String) scrollToEventId; final void Function(String) scrollToEventId;
final void Function() onSwipe; final void Function() onSwipe;
final void Function() onMention; final void Function() onMention;
final void Function() onEdit;
final bool longPressSelect; final bool longPressSelect;
final bool selected; final bool selected;
final bool singleSelected;
final Timeline timeline; final Timeline timeline;
final bool highlightMarker; final bool highlightMarker;
final bool animateIn; final bool animateIn;
@ -52,6 +55,8 @@ class Message extends StatelessWidget {
required this.scrollToEventId, required this.scrollToEventId,
required this.onSwipe, required this.onSwipe,
this.selected = false, this.selected = false,
required this.onEdit,
required this.singleSelected,
required this.timeline, required this.timeline,
this.highlightMarker = false, this.highlightMarker = false,
this.animateIn = false, this.animateIn = false,
@ -157,329 +162,55 @@ class Message extends StatelessWidget {
final resetAnimateIn = this.resetAnimateIn; final resetAnimateIn = this.resetAnimateIn;
var animateIn = this.animateIn; var animateIn = this.animateIn;
final row = StatefulBuilder( final sentReactions = <String>{};
builder: (context, setState) { if (singleSelected) {
if (animateIn && resetAnimateIn != null) { sentReactions.addAll(
WidgetsBinding.instance.addPostFrameCallback((timeStamp) { event
animateIn = false; .aggregatedEvents(
setState(resetAnimateIn); timeline,
}); RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
)
.map(
(event) => event.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key'),
)
.whereType<String>(),
);
} }
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.none,
alignment: ownMessage ? Alignment.bottomRight : Alignment.bottomLeft,
child: animateIn
? const SizedBox(height: 0, width: double.infinity)
: Stack(
children: [
Positioned(
top: 0,
bottom: 0,
left: 0,
right: 0,
child: InkWell(
onTap: () => onSelect(event),
onLongPress: () => onSelect(event),
borderRadius:
BorderRadius.circular(AppConfig.borderRadius / 2),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius / 2),
color: selected || highlightMarker
? theme.colorScheme.secondaryContainer
.withAlpha(128)
: Colors.transparent,
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: rowMainAxisAlignment,
children: [
if (longPressSelect)
SizedBox(
height: 32,
width: Avatar.defaultSize,
child: Checkbox.adaptive(
value: selected,
shape: const CircleBorder(),
onChanged: (_) => onSelect(event),
),
)
else if (nextEventSameSender || ownMessage)
SizedBox(
width: Avatar.defaultSize,
child: Center(
child: SizedBox(
width: 16,
height: 16,
child: event.status == EventStatus.error
? const Icon(Icons.error, color: Colors.red)
: event.fileSendingStatus != null
? const CircularProgressIndicator
.adaptive(
strokeWidth: 1,
)
: null,
),
),
)
else
FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final user = snapshot.data ??
event.senderFromMemoryOrFallback;
return Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
onTap: () => showMemberActionsPopupMenu(
context: context,
user: user,
onMention: onMention,
),
presenceUserId: user.stateKey,
presenceBackgroundColor:
wallpaperMode ? Colors.transparent : null,
);
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!nextEventSameSender)
Padding(
padding: const EdgeInsets.only(
left: 8.0,
bottom: 4,
),
child: ownMessage || event.room.isDirectChat
? const SizedBox(height: 12)
: FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final displayname = snapshot.data
?.calcDisplayname() ??
event.senderFromMemoryOrFallback
.calcDisplayname();
return Text(
displayname,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: (theme.brightness ==
Brightness.light
? displayname.color
: displayname
.lightColorText),
shadows: !wallpaperMode
? null
: [
const Shadow(
offset: Offset(
0.0,
0.0,
),
blurRadius: 3,
color: Colors.black,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
),
Container(
alignment: alignment,
padding: const EdgeInsets.only(left: 8),
child: GestureDetector(
onLongPress: longPressSelect
? null
: () {
HapticFeedback.heavyImpact();
onSelect(event);
},
child: AnimatedOpacity(
opacity: animateIn
? 0
: event.messageType ==
MessageTypes.BadEncrypted ||
event.status.isSending
? 0.5
: 1,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Container(
decoration: BoxDecoration(
color: noBubble
? Colors.transparent
: color,
borderRadius: borderRadius,
),
clipBehavior: Clip.antiAlias,
child: BubbleBackground(
colors: colors,
ignore: noBubble || !ownMessage,
scrollController: scrollController,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
constraints: const BoxConstraints(
maxWidth:
FluffyThemes.columnWidth * 1.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
if ({
RelationshipTypes.reply,
RelationshipTypes.thread,
}.contains(
event.relationshipType,
))
FutureBuilder<Event?>(
future: event
.getReplyEvent(timeline),
builder: (
BuildContext context,
snapshot,
) {
final replyEvent = snapshot
.hasData
? snapshot.data!
: Event(
eventId: event
.relationshipEventId!,
content: {
'msgtype':
'm.text',
'body': '...',
},
senderId:
event.senderId,
type:
'm.room.message',
room: event.room,
status: EventStatus
.sent,
originServerTs:
DateTime.now(),
);
return Padding(
padding:
const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
),
child: Material(
color:
Colors.transparent,
borderRadius:
ReplyContent
.borderRadius,
child: InkWell(
borderRadius:
ReplyContent
.borderRadius,
onTap: () =>
scrollToEventId(
replyEvent.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline:
timeline,
),
),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
linkColor: linkColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
timeline: timeline,
selected: selected,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding:
const EdgeInsets.only(
bottom: 8.0,
left: 16.0,
right: 16.0,
),
child: Row(
mainAxisSize:
MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.edit_outlined,
color: textColor
.withAlpha(164),
size: 14,
),
Text(
displayEvent
.originServerTs
.localizedTimeShort(
context,
),
style: TextStyle(
color: textColor
.withAlpha(164),
fontSize: 11,
),
),
],
),
),
],
),
),
),
),
),
),
),
],
),
),
],
),
],
),
);
},
);
Widget container;
final showReceiptsRow = final showReceiptsRow =
event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); event.hasAggregatedEvents(timeline, RelationshipTypes.reaction);
if (showReceiptsRow || displayTime || selected || displayReadMarker) {
container = Column( return Center(
child: Swipeable(
key: ValueKey(event.eventId),
background: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: Center(
child: Icon(Icons.check_outlined),
),
),
direction: AppConfig.swipeRightToLeftToReply
? SwipeDirection.endToStart
: SwipeDirection.startToEnd,
onSwipe: (_) => onSwipe(),
child: Container(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
top: nextEventSameSender ? 1.0 : 4.0,
bottom: previousEventSameSender ? 1.0 : 4.0,
),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: crossAxisAlignment:
ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start,
@ -514,7 +245,533 @@ class Message extends StatelessWidget {
), ),
), ),
), ),
row, StatefulBuilder(
builder: (context, setState) {
if (animateIn && resetAnimateIn != null) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
animateIn = false;
setState(resetAnimateIn);
});
}
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.none,
alignment: ownMessage
? Alignment.bottomRight
: Alignment.bottomLeft,
child: animateIn
? const SizedBox(height: 0, width: double.infinity)
: Stack(
children: [
Positioned(
top: 0,
bottom: 0,
left: 0,
right: 0,
child: InkWell(
onTap: () => onSelect(event),
onLongPress: () => onSelect(event),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
child: Material(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
color: selected || highlightMarker
? theme.colorScheme.secondaryContainer
.withAlpha(128)
: Colors.transparent,
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: rowMainAxisAlignment,
children: [
if (longPressSelect)
SizedBox(
height: 32,
width: Avatar.defaultSize,
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(
selected
? Icons.check_circle
: Icons.circle_outlined,
),
onPressed: () => onSelect(event),
),
)
else if (nextEventSameSender || ownMessage)
SizedBox(
width: Avatar.defaultSize,
child: Center(
child: SizedBox(
width: 16,
height: 16,
child: event.status ==
EventStatus.error
? const Icon(
Icons.error,
color: Colors.red,
)
: event.fileSendingStatus != null
? const CircularProgressIndicator
.adaptive(
strokeWidth: 1,
)
: null,
),
),
)
else
FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final user = snapshot.data ??
event.senderFromMemoryOrFallback;
return Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
onTap: () =>
showMemberActionsPopupMenu(
context: context,
user: user,
onMention: onMention,
),
presenceUserId: user.stateKey,
presenceBackgroundColor: wallpaperMode
? Colors.transparent
: null,
);
},
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!nextEventSameSender)
Padding(
padding: const EdgeInsets.only(
left: 8.0,
bottom: 4,
),
child: ownMessage ||
event.room.isDirectChat
? const SizedBox(height: 12)
: FutureBuilder<User?>(
future:
event.fetchSenderUser(),
builder:
(context, snapshot) {
final displayname = snapshot
.data
?.calcDisplayname() ??
event
.senderFromMemoryOrFallback
.calcDisplayname();
return Text(
displayname,
style: TextStyle(
fontSize: 11,
fontWeight:
FontWeight.bold,
color: (theme.brightness ==
Brightness
.light
? displayname
.color
: displayname
.lightColorText),
shadows:
!wallpaperMode
? null
: [
const Shadow(
offset:
Offset(
0.0,
0.0,
),
blurRadius:
3,
color: Colors
.black,
),
],
),
maxLines: 1,
overflow: TextOverflow
.ellipsis,
);
},
),
),
Container(
alignment: alignment,
padding:
const EdgeInsets.only(left: 8),
child: GestureDetector(
onLongPress: longPressSelect
? null
: () {
HapticFeedback
.heavyImpact();
onSelect(event);
},
child: AnimatedOpacity(
opacity: animateIn
? 0
: event.messageType ==
MessageTypes
.BadEncrypted ||
event.status.isSending
? 0.5
: 1,
duration: FluffyThemes
.animationDuration,
curve:
FluffyThemes.animationCurve,
child: Container(
decoration: BoxDecoration(
color: noBubble
? Colors.transparent
: color,
borderRadius: borderRadius,
),
clipBehavior: Clip.antiAlias,
child: BubbleBackground(
colors: colors,
ignore:
noBubble || !ownMessage,
scrollController:
scrollController,
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(
AppConfig.borderRadius,
),
),
constraints:
const BoxConstraints(
maxWidth: FluffyThemes
.columnWidth *
1.5,
),
child: Column(
mainAxisSize:
MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment
.start,
children: <Widget>[
if ({
RelationshipTypes
.reply,
RelationshipTypes
.thread,
}.contains(
event
.relationshipType,
))
FutureBuilder<Event?>(
future: event
.getReplyEvent(
timeline,
),
builder: (
BuildContext
context,
snapshot,
) {
final replyEvent =
snapshot
.hasData
? snapshot
.data!
: Event(
eventId:
event.relationshipEventId!,
content: {
'msgtype':
'm.text',
'body':
'...',
},
senderId:
event.senderId,
type:
'm.room.message',
room:
event.room,
status:
EventStatus.sent,
originServerTs:
DateTime.now(),
);
return Padding(
padding:
const EdgeInsets
.only(
left: 16,
right: 16,
top: 8,
),
child: Material(
color: Colors
.transparent,
borderRadius:
ReplyContent
.borderRadius,
child:
InkWell(
borderRadius:
ReplyContent
.borderRadius,
onTap: () =>
scrollToEventId(
replyEvent
.eventId,
),
child:
AbsorbPointer(
child:
ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline:
timeline,
),
),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
linkColor: linkColor,
onInfoTab: onInfoTab,
borderRadius:
borderRadius,
timeline: timeline,
selected: selected,
),
if (event
.hasAggregatedEvents(
timeline,
RelationshipTypes
.edit,
))
Padding(
padding:
const EdgeInsets
.only(
bottom: 8.0,
left: 16.0,
right: 16.0,
),
child: Row(
mainAxisSize:
MainAxisSize
.min,
spacing: 4.0,
children: [
Icon(
Icons
.edit_outlined,
color: textColor
.withAlpha(
164,
),
size: 14,
),
Text(
displayEvent
.originServerTs
.localizedTimeShort(
context,
),
style:
TextStyle(
color: textColor
.withAlpha(
164,
),
fontSize:
11,
),
),
],
),
),
],
),
),
),
),
),
),
),
],
),
),
],
),
],
),
);
},
),
Padding(
padding: const EdgeInsets.only(left: Avatar.defaultSize + 8.0),
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
alignment: Alignment.bottomCenter,
child: singleSelected && event.room.canSendDefaultMessages
? Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Material(
elevation: 4,
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
shadowColor: theme.appBarTheme.shadowColor,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.reply_outlined),
tooltip: L10n.of(context).reply,
onPressed: onSwipe,
),
if (ownMessage)
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: L10n.of(context).edit,
onPressed: onEdit,
),
IconButton(
icon: const Icon(Icons.add_reaction_outlined),
tooltip: L10n.of(context).customReaction,
onPressed: () async {
final emoji = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
CloseButton(
onPressed: () =>
Navigator.of(context)
.pop(null),
),
Text(
L10n.of(context).customReaction,
),
],
),
titlePadding: const EdgeInsets.all(8),
contentPadding: const EdgeInsets.all(0),
clipBehavior: Clip.hardEdge,
content: SizedBox(
width: 350,
height: 350,
child: EmojiPicker(
onEmojiSelected: (_, emoji) =>
Navigator.of(context)
.pop(emoji.emoji),
config: Config(
emojiViewConfig:
const EmojiViewConfig(
backgroundColor:
Colors.transparent,
),
bottomActionBarConfig:
const BottomActionBarConfig(
enabled: false,
),
categoryViewConfig:
CategoryViewConfig(
initCategory: Category.SMILEYS,
backspaceColor:
theme.colorScheme.primary,
iconColor: theme
.colorScheme.primary
.withAlpha(128),
iconColorSelected:
theme.colorScheme.primary,
indicatorColor:
theme.colorScheme.primary,
backgroundColor:
theme.colorScheme.surface,
),
skinToneConfig: SkinToneConfig(
dialogBackgroundColor:
Color.lerp(
theme.colorScheme.surface,
theme.colorScheme
.primaryContainer,
0.75,
)!,
indicatorColor:
theme.colorScheme.onSurface,
),
),
),
),
),
);
if (emoji == null) return;
if (sentReactions.contains(emoji)) return;
await event.room.sendReaction(
event.eventId,
emoji,
);
},
),
...AppConfig.defaultReactions.map(
(emoji) => IconButton(
padding: EdgeInsets.zero,
icon: Center(
child: Opacity(
opacity: sentReactions.contains(emoji)
? 0.33
: 1,
child: Text(
emoji,
style: const TextStyle(fontSize: 20),
textAlign: TextAlign.center,
),
),
),
onPressed: sentReactions.contains(emoji)
? null
: () {
onSelect(event);
event.room.sendReaction(
event.eventId,
emoji,
);
},
),
),
],
),
),
)
: const SizedBox.shrink(),
),
),
AnimatedSize( AnimatedSize(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
@ -533,8 +790,9 @@ class Message extends StatelessWidget {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: child: Divider(
Divider(color: theme.colorScheme.surfaceContainerHighest), color: theme.colorScheme.surfaceContainerHighest,
),
), ),
Container( Container(
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
@ -558,42 +816,15 @@ class Message extends StatelessWidget {
), ),
), ),
Expanded( Expanded(
child: child: Divider(
Divider(color: theme.colorScheme.surfaceContainerHighest), color: theme.colorScheme.surfaceContainerHighest,
),
), ),
], ],
), ),
], ],
);
} else {
container = row;
}
return Center(
child: Swipeable(
key: ValueKey(event.eventId),
background: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: Center(
child: Icon(Icons.check_outlined),
), ),
), ),
direction: AppConfig.swipeRightToLeftToReply
? SwipeDirection.endToStart
: SwipeDirection.startToEnd,
onSwipe: (_) => onSwipe(),
child: Container(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
top: nextEventSameSender ? 1.0 : 4.0,
bottom: previousEventSameSender ? 1.0 : 4.0,
),
child: container,
),
), ),
); );
} }

View file

@ -406,6 +406,7 @@ class InputBar extends StatelessWidget {
builder: (context, controller, focusNode) => TextField( builder: (context, controller, focusNode) => TextField(
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
readOnly: readOnly,
contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller), contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller),
contentInsertionConfiguration: ContentInsertionConfiguration( contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) { onContentInserted: (KeyboardInsertedContent content) {

View file

@ -1,104 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import '../../config/themes.dart';
class ReactionsPicker extends StatelessWidget {
final ChatController controller;
const ReactionsPicker(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (controller.showEmojiPicker) return const SizedBox.shrink();
final display = controller.editEvent == null &&
controller.replyEvent == null &&
controller.room.canSendDefaultMessages &&
controller.selectedEvents.isNotEmpty;
return AnimatedContainer(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
height: (display) ? 56 : 0,
child: Material(
color: Colors.transparent,
child: Builder(
builder: (context) {
if (!display) {
return const SizedBox.shrink();
}
final emojis = List<String>.from(AppEmojis.emojis);
final allReactionEvents = controller.selectedEvents.first
.aggregatedEvents(
controller.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
);
for (final event in allReactionEvents) {
try {
emojis.remove(event.content.tryGetMap('m.relates_to')!['key']);
} catch (_) {}
}
return Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.onInverseSurface,
borderRadius: const BorderRadius.only(
bottomRight: Radius.circular(AppConfig.borderRadius),
),
),
padding: const EdgeInsets.only(right: 1),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: emojis.length,
itemBuilder: (c, i) => InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => controller.sendEmojiAction(emojis[i]),
child: Container(
width: 56,
height: 56,
alignment: Alignment.center,
child: Text(
emojis[i],
style: const TextStyle(fontSize: 30),
),
),
),
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 36,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.onInverseSurface,
shape: BoxShape.circle,
),
child: const Icon(Icons.add_outlined),
),
onTap: () =>
controller.pickEmojiReactionAction(allReactionEvents),
),
],
);
},
),
),
);
}
}

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/config/themes.dart';
Future<void> showScaffoldDialog({ Future<T?> showScaffoldDialog<T>({
required BuildContext context, required BuildContext context,
Color? barrierColor, Color? barrierColor,
Color? containerColor, Color? containerColor,
@ -11,7 +11,7 @@ Future<void> showScaffoldDialog({
double maxHeight = 720, double maxHeight = 720,
required Widget Function(BuildContext context) builder, required Widget Function(BuildContext context) builder,
}) => }) =>
showDialog( showDialog<T>(
context: context, context: context,
useSafeArea: false, useSafeArea: false,
builder: FluffyThemes.isColumnMode(context) builder: FluffyThemes.isColumnMode(context)