diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a4eac082..fc440c69 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2613,5 +2613,9 @@ "@zoomOut": { "type": "text", "placeholders": {} - } + }, + "messageInfo": "Message info", + "time": "Time", + "messageType": "Message Type", + "sender": "Sender" } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index f1d53b64..3502836c 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -8,7 +8,8 @@ abstract class AppConfig { static String _defaultHomeserver = 'matrix.org'; static String get defaultHomeserver => _defaultHomeserver; static String jitsiInstance = 'https://meet.jit.si/'; - static double fontSizeFactor = 1.0; + static double fontSizeFactor = 1; + static const double messageFontSize = 15.75; static const bool allowOtherHomeservers = true; static const bool enableRegistration = true; static const Color primaryColor = Color(0xFF5625BA); @@ -49,7 +50,7 @@ abstract class AppConfig { static const String emojiFontName = 'Noto Emoji'; static const String emojiFontUrl = 'https://github.com/googlefonts/noto-emoji/'; - static const double borderRadius = 12.0; + static const double borderRadius = 16.0; static const double columnWidth = 360.0; static void loadFromJson(Map json) { diff --git a/lib/config/themes.dart b/lib/config/themes.dart index e803d391..fe2d3503 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -62,13 +62,6 @@ abstract class FluffyThemes { borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - ), - ), - ), popupMenuTheme: PopupMenuThemeData( elevation: 4, shape: RoundedRectangleBorder( @@ -83,6 +76,9 @@ abstract class FluffyThemes { style: ElevatedButton.styleFrom( primary: AppConfig.primaryColor, onPrimary: Colors.white, + elevation: 6, + shadowColor: const Color(0x44000000), + minimumSize: const Size.fromHeight(48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), @@ -90,7 +86,8 @@ abstract class FluffyThemes { ), ), cardTheme: CardTheme( - elevation: 4, + elevation: 6, + shadowColor: const Color(0x44000000), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), @@ -109,7 +106,8 @@ abstract class FluffyThemes { fillColor: lighten(AppConfig.primaryColor, .51), ), appBarTheme: const AppBarTheme( - elevation: 2, + elevation: 6, + shadowColor: Color(0x44000000), systemOverlayStyle: SystemUiOverlayStyle.dark, backgroundColor: Colors.white, titleTextStyle: TextStyle( @@ -178,17 +176,11 @@ abstract class FluffyThemes { ), ), ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - ), - ), - ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( primary: AppConfig.primaryColor, onPrimary: Colors.white, + minimumSize: const Size.fromHeight(48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), @@ -197,7 +189,7 @@ abstract class FluffyThemes { ), snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), appBarTheme: const AppBarTheme( - elevation: 2, + elevation: 6, systemOverlayStyle: SystemUiOverlayStyle.light, backgroundColor: Color(0xff1D1D1D), titleTextStyle: TextStyle( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 33a89fb2..7be4ae1a 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -21,6 +21,7 @@ import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat_view.dart'; +import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -790,6 +791,9 @@ class ChatController extends State { setState(() => inputText = text); } + void showEventInfo([Event event]) => + (event ?? selectedEvents.single).showInfoDialog(context); + void cancelReplyEventAction() => setState(() { if (editEvent != null) { inputText = sendController.text = pendingText; diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index e5233aaf..c1ce9373 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/reactions_picker.dart'; import 'package:fluffychat/pages/chat/reply_display.dart'; +import 'package:fluffychat/pages/chat/seen_by_row.dart'; import 'package:fluffychat/pages/chat/tombstone_display.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; @@ -30,6 +31,8 @@ import 'chat_emoji_picker.dart'; import 'chat_input_row.dart'; import 'events/message.dart'; +enum _EventContextAction { info, report } + class ChatView extends StatelessWidget { final ChatController controller; @@ -56,6 +59,7 @@ class ChatView extends StatelessWidget { showFutureLoadingDialog( context: context, future: () => controller.room.join()); } + final bottomSheetPadding = FluffyThemes.isColumnMode(context) ? 16.0 : 8.0; return VWidgetGuard( onSystemPop: (redirector) async { @@ -159,18 +163,50 @@ class ChatView extends StatelessWidget { tooltip: L10n.of(context).copy, onPressed: controller.copyEventsAction, ), - if (controller.selectedEvents.length == 1) - IconButton( - icon: const Icon(Icons.report_outlined), - tooltip: L10n.of(context).reportMessage, - onPressed: controller.reportEventAction, - ), if (controller.canRedactSelectedEvents) IconButton( icon: const Icon(Icons.delete_outlined), tooltip: L10n.of(context).redactMessage, onPressed: controller.redactEventsAction, ), + if (controller.selectedEvents.length == 1) + PopupMenuButton<_EventContextAction>( + onSelected: (action) { + switch (action) { + case _EventContextAction.info: + controller.showEventInfo(); + controller.clearSelectedEvents(); + break; + case _EventContextAction.report: + controller.reportEventAction(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _EventContextAction.info, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.info_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).messageInfo), + ], + ), + ), + PopupMenuItem( + value: _EventContextAction.report, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.report_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).reportMessage), + ], + ), + ), + ], + ), ] : [ if (controller.room.canSendDefaultStates) @@ -197,6 +233,8 @@ class ChatView extends StatelessWidget { ), ) : null, + backgroundColor: Theme.of(context).primaryColor.withAlpha( + Theme.of(context).brightness == Brightness.light ? 15 : 70), body: Stack( children: [ if (Matrix.of(context).wallpaper != null) @@ -206,199 +244,155 @@ class ChatView extends StatelessWidget { height: double.infinity, fit: BoxFit.cover, ), - SafeArea( - child: Column( - children: [ - TombstoneDisplay(controller), - Expanded( - child: FutureBuilder( - future: controller.getTimeline(), - builder: (BuildContext context, snapshot) { - if (controller.timeline == null) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2), - ); - } - - // create a map of eventId --> index to greatly improve performance of - // ListView's findChildIndexCallback - final thisEventsKeyMap = {}; - for (var i = 0; - i < controller.filteredEvents.length; - i++) { - thisEventsKeyMap[ - controller.filteredEvents[i].eventId] = i; - } - final seenByText = - controller.room.getLocalizedSeenByText( - context, - controller.timeline, - controller.filteredEvents, - controller.unfolded, + Column( + children: [ + TombstoneDisplay(controller), + Expanded( + child: FutureBuilder( + future: controller.getTimeline(), + builder: (BuildContext context, snapshot) { + if (controller.timeline == null) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2), ); - return ListView.custom( - padding: const EdgeInsets.only( - top: 16, - bottom: 4, - ), - reverse: true, - controller: controller.scrollController, - keyboardDismissBehavior: PlatformInfos.isIOS - ? ScrollViewKeyboardDismissBehavior.onDrag - : ScrollViewKeyboardDismissBehavior.manual, - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int i) { - return i == controller.filteredEvents.length + 1 - ? controller.timeline.isRequestingHistory - ? const Center( - child: CircularProgressIndicator - .adaptive(strokeWidth: 2), - ) - : controller.canLoadMore - ? Center( - child: OutlinedButton( - style: - OutlinedButton.styleFrom( - backgroundColor: Theme.of( - context) - .scaffoldBackgroundColor, - ), - onPressed: - controller.requestHistory, - child: Text(L10n.of(context) - .loadMore), + } + + // create a map of eventId --> index to greatly improve performance of + // ListView's findChildIndexCallback + final thisEventsKeyMap = {}; + for (var i = 0; + i < controller.filteredEvents.length; + i++) { + thisEventsKeyMap[ + controller.filteredEvents[i].eventId] = i; + } + return ListView.custom( + padding: const EdgeInsets.only( + top: 16, + bottom: 4, + ), + reverse: true, + controller: controller.scrollController, + keyboardDismissBehavior: PlatformInfos.isIOS + ? ScrollViewKeyboardDismissBehavior.onDrag + : ScrollViewKeyboardDismissBehavior.manual, + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int i) { + return i == controller.filteredEvents.length + 1 + ? controller.timeline.isRequestingHistory + ? const Center( + child: CircularProgressIndicator + .adaptive(strokeWidth: 2), + ) + : controller.canLoadMore + ? Center( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Theme.of( + context) + .scaffoldBackgroundColor, ), - ) - : Container() - : i == 0 - ? AnimatedContainer( - height: seenByText.isEmpty ? 0 : 24, - duration: seenByText.isEmpty - ? const Duration( - milliseconds: 0) - : const Duration( - milliseconds: 300), - alignment: controller.filteredEvents - .isNotEmpty && - controller.filteredEvents - .first.senderId == - client.userID - ? Alignment.topRight - : Alignment.topLeft, - padding: const EdgeInsets.only( - left: 8, - right: 8, - bottom: 8, - ), - child: Container( - padding: - const EdgeInsets.symmetric( - horizontal: 4), - decoration: BoxDecoration( - color: Theme.of(context) - .scaffoldBackgroundColor - .withOpacity(0.8), - borderRadius: - BorderRadius.circular(4), + onPressed: + controller.requestHistory, + child: Text( + L10n.of(context).loadMore), ), - child: Text( - seenByText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .secondary, - ), - ), - ), - ) - : AutoScrollTag( + ) + : Container() + : i == 0 + ? SeenByRow(controller) + : AutoScrollTag( + key: ValueKey(controller + .filteredEvents[i - 1].eventId), + index: i - 1, + controller: + controller.scrollController, + child: Swipeable( key: ValueKey(controller .filteredEvents[i - 1].eventId), - index: i - 1, - controller: - controller.scrollController, - child: Swipeable( - key: ValueKey(controller - .filteredEvents[i - 1] - .eventId), - background: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 12.0), - child: Center( - child: Icon( - Icons.reply_outlined), - ), + background: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 12.0), + child: Center( + child: + Icon(Icons.reply_outlined), ), - direction: - SwipeDirection.endToStart, - onSwipe: (direction) => - controller.replyAction( - replyTo: controller - .filteredEvents[ - i - 1]), - child: Message( - controller - .filteredEvents[i - 1], - onAvatarTab: (Event event) => - showModalBottomSheet( - context: context, - builder: (c) => - UserBottomSheet( - user: event.sender, - outerContext: context, - onMention: () => controller - .sendController - .text += - '${event.sender.mention} ', - ), - ), - unfold: controller.unfold, - onSelect: controller - .onSelectMessage, - scrollToEventId: (String eventId) => - controller.scrollToEventId( - eventId), - longPressSelect: controller - .selectedEvents.isEmpty, - selected: controller - .selectedEvents - .contains( - controller.filteredEvents[ - i - 1]), - timeline: controller.timeline, - nextEvent: i >= 2 - ? controller.filteredEvents[i - 2] - : null), ), - ); - }, - childCount: controller.filteredEvents.length + 2, - findChildIndexCallback: (key) => - controller.findChildIndexCallback( - key, thisEventsKeyMap), - ), - ); - }, - ), + direction: + SwipeDirection.endToStart, + onSwipe: (direction) => + controller.replyAction( + replyTo: controller + .filteredEvents[i - 1]), + child: Message( + controller + .filteredEvents[i - 1], + onInfoTab: + controller.showEventInfo, + onAvatarTab: (Event event) => + showModalBottomSheet( + context: context, + builder: (c) => + UserBottomSheet( + user: event.sender, + outerContext: context, + onMention: () => controller + .sendController + .text += + '${event.sender.mention} ', + ), + ), + unfold: controller.unfold, + onSelect: + controller.onSelectMessage, + scrollToEventId: (String eventId) => + controller.scrollToEventId( + eventId), + longPressSelect: controller + .selectedEvents.isEmpty, + selected: controller + .selectedEvents + .contains(controller + .filteredEvents[i - 1]), + timeline: controller.timeline, + nextEvent: i < + controller + .filteredEvents + .length + ? controller.filteredEvents[i] + : null), + ), + ); + }, + childCount: controller.filteredEvents.length + 2, + findChildIndexCallback: (key) => controller + .findChildIndexCallback(key, thisEventsKeyMap), + ), + ); + }, ), - if (controller.showScrollDownButton) - const Divider( - height: 1, + ), + if (controller.room.canSendDefaultMessages && + controller.room.membership == Membership.join) + Padding( + padding: EdgeInsets.only( + bottom: bottomSheetPadding, + left: bottomSheetPadding, + right: bottomSheetPadding, ), - if (controller.room.canSendDefaultMessages && - controller.room.membership == Membership.join) - Padding( - padding: EdgeInsets.all( - FluffyThemes.isColumnMode(context) ? 16.0 : 8.0), - child: Material( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - elevation: 7, - clipBehavior: Clip.hardEdge, - color: Theme.of(context).appBarTheme.backgroundColor, + child: Material( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(AppConfig.borderRadius), + bottomRight: Radius.circular(AppConfig.borderRadius), + ), + elevation: 6, + shadowColor: Theme.of(context) + .secondaryHeaderColor + .withAlpha(100), + clipBehavior: Clip.hardEdge, + color: Theme.of(context).backgroundColor, + child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -411,8 +405,8 @@ class ChatView extends StatelessWidget { ), ), ), - ], - ), + ), + ], ), ], ), diff --git a/lib/pages/chat/event_info_dialog.dart b/lib/pages/chat/event_info_dialog.dart new file mode 100644 index 00000000..fda93de9 --- /dev/null +++ b/lib/pages/chat/event_info_dialog.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +extension EventInfoDialogExtension on Event { + void showInfoDialog(BuildContext context) => showModalBottomSheet( + context: context, + builder: (context) => + EventInfoDialog(l10n: L10n.of(context), event: this), + ); +} + +class EventInfoDialog extends StatelessWidget { + final Event event; + final L10n l10n; + const EventInfoDialog({ + @required this.event, + @required this.l10n, + Key key, + }) : super(key: key); + + String get prettyJson { + const JsonDecoder decoder = JsonDecoder(); + const JsonEncoder encoder = JsonEncoder.withIndent(' '); + final object = decoder.convert(jsonEncode(event.toJson())); + return encoder.convert(object); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).messageInfo), + leading: IconButton( + icon: const Icon(Icons.arrow_downward_outlined), + onPressed: Navigator.of(context, rootNavigator: false).pop, + tooltip: L10n.of(context).close, + ), + ), + body: ListView( + children: [ + ListTile( + leading: + Avatar(event.sender.avatarUrl, event.sender.calcDisplayname()), + title: Text(L10n.of(context).sender), + subtitle: + Text('${event.sender.calcDisplayname()} <${event.senderId}>'), + ), + ListTile( + title: Text(L10n.of(context).time), + subtitle: Text(event.originServerTs.localizedTime(context)), + ), + ListTile( + title: Text(L10n.of(context).messageType), + subtitle: Text(event.humanreadableType), + ), + ListTile( + title: Text(L10n.of(context).sourceCode), + ), + Padding( + padding: const EdgeInsets.all(12.0), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + prettyJson, + style: const TextStyle( + fontFamily: 'Roboto-Mono', + fontSize: 16, + ), + ), + ), + ), + ], + ), + ); + } +} + +extension on Event { + String get humanreadableType { + if (type == EventTypes.Message) { + return messageType.split('m.').last; + } + if (type.startsWith('m.room.')) { + return type.split('m.room.').last; + } + if (type.startsWith('m.')) { + return type.split('m.').last; + } + return type; + } +} diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index b73ab1a3..f9497a07 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -4,7 +4,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -20,6 +19,7 @@ class Message extends StatelessWidget { final Event nextEvent; final void Function(Event) onSelect; final void Function(Event) onAvatarTab; + final void Function(Event) onInfoTab; final void Function(String) scrollToEventId; final void Function(String) unfold; final bool longPressSelect; @@ -30,6 +30,7 @@ class Message extends StatelessWidget { {this.nextEvent, this.longPressSelect, this.onSelect, + this.onInfoTab, this.onAvatarTab, this.scrollToEventId, @required this.unfold, @@ -57,12 +58,19 @@ class Message extends StatelessWidget { final client = Matrix.of(context).client; final ownMessage = event.senderId == client.userID; final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; - var color = Theme.of(context).secondaryHeaderColor; + var color = Theme.of(context).scaffoldBackgroundColor; + final displayTime = event.type == EventTypes.RoomCreate || + nextEvent == null || + !event.originServerTs.sameEnvironment(nextEvent.originServerTs); final sameSender = nextEvent != null && - [EventTypes.Message, EventTypes.Sticker].contains(nextEvent.type) - ? nextEvent.sender.id == event.sender.id + [ + EventTypes.Message, + EventTypes.Sticker, + EventTypes.Encrypted, + ].contains(nextEvent.type) + ? nextEvent.sender.id == event.sender.id && !displayTime : false; - var textColor = ownMessage + final textColor = ownMessage ? Colors.white : Theme.of(context).brightness == Brightness.dark ? Colors.white @@ -71,148 +79,182 @@ class Message extends StatelessWidget { ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; final displayEvent = event.getDisplayEvent(timeline); + final borderRadius = BorderRadius.only( + topLeft: !ownMessage + ? const Radius.circular(2) + : const Radius.circular(AppConfig.borderRadius), + topRight: ownMessage + ? const Radius.circular(2) + : const Radius.circular(AppConfig.borderRadius), + bottomLeft: const Radius.circular(AppConfig.borderRadius), + bottomRight: const Radius.circular(AppConfig.borderRadius), + ); - if (event.showThumbnail) { - color = Colors.transparent; - textColor = Theme.of(context).textTheme.bodyText2.color; - } else if (ownMessage) { + if (ownMessage) { color = displayEvent.status.isError ? Colors.redAccent : Theme.of(context).primaryColor; } final rowChildren = [ + sameSender || ownMessage + ? SizedBox( + width: Avatar.defaultSize, + height: Avatar.defaultSize, + child: event.status == EventStatus.sending + ? const Center( + child: SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ) + : null, + ) + : Avatar( + event.sender.avatarUrl, + event.sender.calcDisplayname(), + onTap: () => onAvatarTab(event), + ), Expanded( - child: Container( - alignment: alignment, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Material( - color: color, - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: InkWell( - onHover: (b) => useMouse = true, - onTap: !useMouse && longPressSelect - ? () => null - : () => onSelect(event), - onLongPress: !longPressSelect ? null : () => onSelect(event), - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - ), - padding: - const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - constraints: const BoxConstraints( - maxWidth: FluffyThemes.columnWidth * 1.5), - child: Stack( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (event.relationshipType == RelationshipTypes.reply) - FutureBuilder( - 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 InkWell( - onTap: () { - if (scrollToEventId != null) { - scrollToEventId(replyEvent.eventId); - } - }, - child: AbsorbPointer( - child: Container( - margin: const EdgeInsets.symmetric( - vertical: 4.0), - child: ReplyContent(replyEvent, - lightText: ownMessage, - timeline: timeline), - ), - ), - ); - }, - ), - MessageContent( - displayEvent, - textColor: textColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (!ownMessage && !sameSender) + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 4), + child: event.room.isDirectChat + ? const SizedBox(height: 12) + : Text( + event.sender.calcDisplayname(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: event.sender.calcDisplayname().color, ), - const SizedBox(height: 3), - Opacity( - opacity: 0, - child: _MetaRow( - event, // meta information should be from the unedited event - ownMessage, - textColor, - timeline, - displayEvent, - ), + ), + ), + Container( + alignment: alignment, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Material( + color: color, + elevation: 6, + shadowColor: + Theme.of(context).secondaryHeaderColor.withAlpha(100), + borderRadius: borderRadius, + child: InkWell( + onHover: (b) => useMouse = true, + onTap: !useMouse && longPressSelect + ? () => null + : () => onSelect(event), + onLongPress: !longPressSelect ? null : () => onSelect(event), + borderRadius: borderRadius, + child: Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + padding: const EdgeInsets.all(16), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 1.5), + child: Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.relationshipType == + RelationshipTypes.reply) + FutureBuilder( + 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 InkWell( + onTap: () { + if (scrollToEventId != null) { + scrollToEventId(replyEvent.eventId); + } + }, + child: AbsorbPointer( + child: Container( + margin: const EdgeInsets.symmetric( + vertical: 4.0), + child: ReplyContent(replyEvent, + lightText: ownMessage, + timeline: timeline), + ), + ), + ); + }, + ), + MessageContent( + displayEvent, + textColor: textColor, + onInfoTab: onInfoTab, + ), + ], ), ], ), - Positioned( - bottom: 0, - right: ownMessage ? 0 : null, - left: !ownMessage ? 0 : null, - child: _MetaRow( - event, - ownMessage, - textColor, - timeline, - displayEvent, - ), - ), - ], + ), ), ), ), - ), + ], ), ), ]; - final avatarOrSizedBox = sameSender - ? const SizedBox(width: Avatar.defaultSize) - : Avatar( - event.sender.avatarUrl, - event.sender.calcDisplayname(), - onTap: () => onAvatarTab(event), - ); - if (ownMessage) { - rowChildren.add(avatarOrSizedBox); - } else { - rowChildren.insert(0, avatarOrSizedBox); - } final row = Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: rowMainAxisAlignment, children: rowChildren, ); Widget container; - if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction)) { + if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction) || + displayTime) { container = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: ownMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ + if (displayTime) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Center( + child: Material( + color: Theme.of(context).backgroundColor, + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + event.originServerTs.localizedTime(context), + style: const TextStyle(fontSize: 12), + ), + ), + )), + ), row, Padding( padding: EdgeInsets.only( top: 4.0, left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0, - right: (ownMessage ? Avatar.defaultSize : 0) + 12.0, + right: 12.0, ), child: MessageReactions(event, timeline), ), @@ -238,64 +280,3 @@ class Message extends StatelessWidget { ); } } - -class _MetaRow extends StatelessWidget { - final Event event; - final bool ownMessage; - final Color color; - final Timeline timeline; - final Event displayEvent; - - const _MetaRow( - this.event, this.ownMessage, this.color, this.timeline, this.displayEvent, - {Key key}) - : super(key: key); - - @override - Widget build(BuildContext context) { - final displayname = event.sender.calcDisplayname(); - final showDisplayname = - !ownMessage && event.senderId != event.room.directChatMatrixID; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (showDisplayname) - Text( - displayname, - style: TextStyle( - fontSize: 10 * AppConfig.fontSizeFactor, - fontWeight: FontWeight.bold, - color: (Theme.of(context).brightness == Brightness.light - ? displayname.darkColor - : displayname.lightColor) - .withAlpha(200), - ), - ), - if (showDisplayname) const SizedBox(width: 4), - Text( - event.originServerTs.localizedTime(context), - style: TextStyle( - color: color.withAlpha(180), - fontSize: 10 * AppConfig.fontSizeFactor, - ), - ), - if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) - Padding( - padding: const EdgeInsets.only(left: 2.0), - child: Icon( - Icons.edit_outlined, - size: 12 * AppConfig.fontSizeFactor, - color: color, - ), - ), - if (ownMessage) const SizedBox(width: 2), - if (ownMessage) - Icon( - displayEvent.statusIcon, - size: 14 * AppConfig.fontSizeFactor, - color: color, - ), - ], - ); - } -} diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index f9e36aed..0193069e 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -25,8 +25,10 @@ import 'sticker.dart'; class MessageContent extends StatelessWidget { final Event event; final Color textColor; + final void Function(Event) onInfoTab; - const MessageContent(this.event, {Key key, this.textColor}) : super(key: key); + const MessageContent(this.event, {this.onInfoTab, Key key, this.textColor}) + : super(key: key); void _verifyOrRequestKey(BuildContext context) async { if (event.content['can_request_session'] != true) { @@ -72,8 +74,7 @@ class MessageContent extends StatelessWidget { @override Widget build(BuildContext context) { - final fontSize = - DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor; + final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; switch (event.type) { case EventTypes.Message: case EventTypes.Encrypted: @@ -163,14 +164,11 @@ class MessageContent extends StatelessWidget { continue textmessage; case MessageTypes.BadEncrypted: case EventTypes.Encrypted: - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - primary: Theme.of(context).scaffoldBackgroundColor, - onPrimary: Theme.of(context).textTheme.bodyText1.color, - ), + return _ButtonContent( + textColor: textColor, onPressed: () => _verifyOrRequestKey(context), icon: const Icon(Icons.lock_outline), - label: Text(L10n.of(context).encrypted), + label: L10n.of(context).encrypted, ); case MessageTypes.Location: final geoUri = @@ -213,34 +211,20 @@ class MessageContent extends StatelessWidget { textmessage: default: if (event.content['msgtype'] == Matrix.callNamespace) { - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - primary: Theme.of(context).scaffoldBackgroundColor, - onPrimary: Theme.of(context).textTheme.bodyText1.color, - ), + return _ButtonContent( onPressed: () => launch(event.body), icon: const Icon(Icons.phone_outlined, color: Colors.green), - label: Text(L10n.of(context).videoCall), + label: L10n.of(context).videoCall, + textColor: textColor, ); } if (event.redacted) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.delete_forever_outlined, color: textColor), - const SizedBox(width: 4), - Text( - event.getLocalizedBody(MatrixLocals(L10n.of(context)), - hideReply: true), - style: TextStyle( - color: textColor, - fontSize: fontSize, - fontWeight: FontWeight.bold, - decoration: TextDecoration.lineThrough, - decorationThickness: 0.5, - ), - ), - ], + return _ButtonContent( + label: L10n.of(context) + .redactedAnEvent(event.sender.calcDisplayname()), + icon: const Icon(Icons.delete_outlined), + textColor: textColor, + onPressed: () => onInfoTab(event), ); } final bigEmotes = event.onlyEmotes && @@ -264,15 +248,42 @@ class MessageContent extends StatelessWidget { } break; default: - return Text( - L10n.of(context) + return _ButtonContent( + label: L10n.of(context) .userSentUnknownEvent(event.sender.calcDisplayname(), event.type), - style: TextStyle( - color: textColor, - decoration: event.redacted ? TextDecoration.lineThrough : null, - ), + icon: const Icon(Icons.info_outlined), + textColor: textColor, + onPressed: () => onInfoTab(event), ); } return Container(); // else flutter analyze complains } } + +class _ButtonContent extends StatelessWidget { + final void Function() onPressed; + final String label; + final Icon icon; + final Color textColor; + + const _ButtonContent({ + @required this.label, + @required this.icon, + @required this.textColor, + @required this.onPressed, + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom( + primary: textColor, + textStyle: TextStyle(color: textColor), + ), + onPressed: onPressed, + icon: icon, + label: Text(label), + ); + } +} diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index e8f9cb50..24c142a2 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -6,6 +6,7 @@ import 'package:characters/characters.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -100,13 +101,12 @@ class _Reaction extends StatelessWidget { Widget build(BuildContext context) { final borderColor = reacted ? Theme.of(context).primaryColor - : Theme.of(context).secondaryHeaderColor; + : Theme.of(context).dividerColor; final textColor = Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black; final color = Theme.of(context).scaffoldBackgroundColor; final fontSize = DefaultTextStyle.of(context).style.fontSize; - final padding = fontSize / 5; Widget content; if (reactionKey.startsWith('mxc://')) { final src = Uri.parse(reactionKey)?.getThumbnail( @@ -122,7 +122,7 @@ class _Reaction extends StatelessWidget { imageUrl: src.toString(), height: fontSize, ), - Container(width: 4), + const SizedBox(width: 4), Text(count.toString(), style: TextStyle( color: textColor, @@ -144,6 +144,7 @@ class _Reaction extends StatelessWidget { return InkWell( onTap: () => onTap != null ? onTap() : null, onLongPress: () => onLongPress != null ? onLongPress() : null, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), child: Container( decoration: BoxDecoration( color: color, @@ -151,9 +152,9 @@ class _Reaction extends StatelessWidget { width: 1, color: borderColor, ), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), - padding: EdgeInsets.all(padding), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), child: content, ), ); diff --git a/lib/pages/chat/events/reply_content.dart b/lib/pages/chat/events/reply_content.dart index b864485e..a6d935f0 100644 --- a/lib/pages/chat/events/reply_content.dart +++ b/lib/pages/chat/events/reply_content.dart @@ -22,8 +22,7 @@ class ReplyContent extends StatelessWidget { final displayEvent = replyEvent != null && timeline != null ? replyEvent.getDisplayEvent(timeline) : replyEvent; - final fontSize = - DefaultTextStyle.of(context).style.fontSize * AppConfig.fontSizeFactor; + final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; if (displayEvent != null && AppConfig.renderHtml && [EventTypes.Message, EventTypes.Encrypted] diff --git a/lib/pages/chat/events/state_message.dart b/lib/pages/chat/events/state_message.dart index 88e0c4ca..d161b5e0 100644 --- a/lib/pages/chat/events/state_message.dart +++ b/lib/pages/chat/events/state_message.dart @@ -31,11 +31,8 @@ class StateMessage extends StatelessWidget { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - ), color: Theme.of(context).scaffoldBackgroundColor, - borderRadius: BorderRadius.circular(AppConfig.borderRadius), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -44,8 +41,7 @@ class StateMessage extends StatelessWidget { event.getLocalizedBody(MatrixLocals(L10n.of(context))), textAlign: TextAlign.center, style: TextStyle( - fontSize: Theme.of(context).textTheme.bodyText1.fontSize * - AppConfig.fontSizeFactor, + fontSize: Theme.of(context).textTheme.bodyText1.fontSize, color: Theme.of(context).textTheme.bodyText2.color, decoration: event.redacted ? TextDecoration.lineThrough : null, diff --git a/lib/pages/chat/seen_by_row.dart b/lib/pages/chat/seen_by_row.dart new file mode 100644 index 00000000..2ec196b4 --- /dev/null +++ b/lib/pages/chat/seen_by_row.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/utils/room_status_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SeenByRow extends StatelessWidget { + final ChatController controller; + const SeenByRow(this.controller, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final seenByUsers = controller.room.getSeenByUsers( + controller.timeline, + controller.filteredEvents, + controller.unfolded, + ); + const maxAvatars = 7; + return AnimatedContainer( + height: seenByUsers.isEmpty ? 0 : 24, + duration: seenByUsers.isEmpty + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), + alignment: controller.filteredEvents.isNotEmpty && + controller.filteredEvents.first.senderId == + Matrix.of(context).client.userID + ? Alignment.topRight + : Alignment.topLeft, + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 4, + ), + child: Wrap( + spacing: 4, + children: [ + ...(seenByUsers.length > maxAvatars + ? seenByUsers.sublist(0, maxAvatars) + : seenByUsers) + .map( + (user) => Avatar( + user.avatarUrl, + user.calcDisplayname(), + size: 16, + ), + ) + .toList(), + if (seenByUsers.length > maxAvatars) + SizedBox( + width: 16, + height: 16, + child: Material( + color: Theme.of(context).backgroundColor, + borderRadius: BorderRadius.circular(32), + child: Center( + child: Text( + '+${seenByUsers.length - maxAvatars}', + style: const TextStyle(fontSize: 10), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index c0aab6fc..09b74376 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -8,7 +8,6 @@ import 'package:pedantic/pedantic.dart'; import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; import 'package:fluffychat/utils/room_status_extension.dart'; import '../../utils/date_time_extension.dart'; @@ -182,7 +181,7 @@ class ChatListItem extends StatelessWidget { overflow: TextOverflow.ellipsis, softWrap: false, style: TextStyle( - fontWeight: unread ? FontWeight.bold : null, + fontWeight: FontWeight.bold, color: unread ? Theme.of(context).colorScheme.secondary : Theme.of(context).textTheme.bodyText1.color, @@ -224,13 +223,16 @@ class ChatListItem extends StatelessWidget { subtitle: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (typingText.isEmpty && ownMessage) ...{ - Icon( - room.lastEvent.statusIcon, - size: 14, + if (typingText.isEmpty && + ownMessage && + room.lastEvent.status.isSending) ...[ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), ), const SizedBox(width: 4), - }, + ], AnimatedContainer( width: typingText.isEmpty ? 0 : 18, clipBehavior: Clip.hardEdge, @@ -244,19 +246,6 @@ class ChatListItem extends StatelessWidget { size: 14, ), ), - if (typingText.isEmpty && - !ownMessage && - !room.isDirectChat && - room.lastEvent != null && - room.lastEvent.type == EventTypes.Message && - {MessageTypes.Text, MessageTypes.Notice} - .contains(room.lastEvent.messageType)) - Text( - '${room.lastEvent.sender.calcDisplayname()}: ', - style: TextStyle( - color: Theme.of(context).textTheme.bodyText1.color, - ), - ), Expanded( child: typingText.isNotEmpty ? Text( @@ -274,6 +263,7 @@ class ChatListItem extends StatelessWidget { hideReply: true, hideEdit: true, plaintextBody: true, + withSenderNamePrefix: true, ) ?? L10n.of(context).emptyChat, softWrap: false, diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index 1cf49f94..b723c849 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -86,19 +86,20 @@ class SettingsStyleView extends StatelessWidget { ), Container( alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: - const EdgeInsets.symmetric(vertical: 6, horizontal: 10), - decoration: BoxDecoration( - color: Theme.of(context).secondaryHeaderColor, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor', - style: TextStyle( - fontSize: Theme.of(context).textTheme.bodyText1.fontSize * - AppConfig.fontSizeFactor, + child: Material( + color: Theme.of(context).backgroundColor, + elevation: 6, + shadowColor: + Theme.of(context).secondaryHeaderColor.withAlpha(100), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor', + style: TextStyle( + fontSize: + AppConfig.messageFontSize * AppConfig.fontSizeFactor, + ), ), ), ), diff --git a/lib/utils/matrix_sdk_extensions.dart/event_extension.dart b/lib/utils/matrix_sdk_extensions.dart/event_extension.dart index f3b62456..4b3571d4 100644 --- a/lib/utils/matrix_sdk_extensions.dart/event_extension.dart +++ b/lib/utils/matrix_sdk_extensions.dart/event_extension.dart @@ -17,21 +17,6 @@ extension LocalizedBody on Event { matrixFile.result?.save(context); } - IconData get statusIcon { - switch (status.intValue) { - case -1: - return Icons.error_outline; - case 0: - return Icons.timer_outlined; - case 1: - return Icons.done_outlined; - case 2: - return Icons.done_all_outlined; - default: - return Icons.done_outlined; - } - } - bool get isAttachmentSmallEnough => infoMap['size'] is int && infoMap['size'] < room.client.database.maxFileSize; diff --git a/lib/utils/room_status_extension.dart b/lib/utils/room_status_extension.dart index 3e9ea4a1..039e0beb 100644 --- a/lib/utils/room_status_extension.dart +++ b/lib/utils/room_status_extension.dart @@ -65,38 +65,26 @@ extension RoomStatusExtension on Room { return typingText; } - String getLocalizedSeenByText( - BuildContext context, + List getSeenByUsers( Timeline timeline, List filteredEvents, Set unfolded, ) { - var seenByText = ''; - if (timeline.events.isNotEmpty) { - final filteredEvents = timeline.getFilteredEvents(unfolded: unfolded); - if (filteredEvents.isEmpty) return ''; - final lastReceipts = {}; - // now we iterate the timeline events until we hit the first rendered event - for (final event in timeline.events) { - lastReceipts.addAll(event.receipts.map((r) => r.user)); - if (event.eventId == filteredEvents.first.eventId) { - break; - } - } - lastReceipts.removeWhere((user) => - user.id == client.userID || user.id == filteredEvents.first.senderId); - if (lastReceipts.length == 1) { - seenByText = - L10n.of(context).seenByUser(lastReceipts.first.calcDisplayname()); - } else if (lastReceipts.length == 2) { - seenByText = seenByText = L10n.of(context).seenByUserAndUser( - lastReceipts.first.calcDisplayname(), - lastReceipts.last.calcDisplayname()); - } else if (lastReceipts.length > 2) { - seenByText = L10n.of(context).seenByUserAndCountOthers( - lastReceipts.first.calcDisplayname(), lastReceipts.length - 1); + if (timeline.events.isEmpty) return []; + + final filteredEvents = timeline.getFilteredEvents(unfolded: unfolded); + if (filteredEvents.isEmpty) return []; + + final lastReceipts = {}; + // now we iterate the timeline events until we hit the first rendered event + for (final event in timeline.events) { + lastReceipts.addAll(event.receipts.map((r) => r.user)); + if (event.eventId == filteredEvents.first.eventId) { + break; } } - return seenByText; + lastReceipts.removeWhere((user) => + user.id == client.userID || user.id == filteredEvents.first.senderId); + return lastReceipts.toList(); } }