diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb73ff7f18..0678c24ad77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All user visible changes to this project will be documented in this file. This p - Chat page: - Redesigned info and call messages. ([#357]) - Redesigned file attachments. ([#362]) + - Message timestamps. ([#399]) - Media panel: - Position and size persistence. ([#270], [#264]) - Proportionally resizing secondary panel. ([#393], [#356], [#258]) @@ -64,6 +65,7 @@ All user visible changes to this project will be documented in this file. This p [#380]: /../../pull/380 [#388]: /../../pull/388 [#393]: /../../pull/393 +[#399]: /../../pull/399 [#403]: /../../pull/403 diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index dc23dfe5e04..75eb49c9048 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -378,6 +378,7 @@ label_app_background = Application background label_application = Application label_are_you_sure_no = No label_are_you_sure_yes = Yes +label_as_timeline = As timeline label_attachments = [{$count} { $count -> [1] attachment *[other] attachments @@ -476,6 +477,7 @@ label_direct_chat_link_in_chat_description = - send messages to group chat, - make calls label_disabled = Disabled +label_display_timestamps = Display time stamps label_download = Download label_download_application = Download application label_draft = Draft @@ -522,6 +524,7 @@ label_hint_drag_n_drop_video = label_hint_from_gapopa = Hint from Gapopa label_image_downloaded = Image downloaded. label_image_saved_to_gallery = Image saved to gallery. +label_in_message = In message label_incoming_call = Incoming call label_introduction_description = Password is not set. Access to an account without a password is retained for one year from the time the account was created or until: @@ -672,6 +675,7 @@ label_subtitle_participants = participants label_tab_chats = Chats label_tab_contacts = Contacts label_tab_menu = Menu +label_timeline_style = Timeline style label_transition_count = Transitions: {$count} label_typing = Typing label_unconfirmed = Unconfirmed diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index cdd07795269..a1e2fddd33d 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -392,6 +392,7 @@ label_app_background = Фон приложения label_application = Приложение label_are_you_sure_no = Нет label_are_you_sure_yes = Да +label_as_timeline = Как таймлайн label_attachments = [{$count} { $count -> [1] прикрепление [few] прикрепления @@ -492,6 +493,7 @@ label_direct_chat_link_in_chat_description = - отправлять сообщения в чат группы, - совершать звонки label_disabled = Отключены +label_display_timestamps = Отображать метки времени label_download = Скачать label_download_application = Скачать приложение label_draft = Черновик @@ -538,6 +540,7 @@ label_hint_drag_n_drop_video = label_hint_from_gapopa = Подсказка от Gapopa label_image_downloaded = Изображение загружено. label_image_saved_to_gallery = Изображение сохранено в галерею. +label_in_message = В сообщении label_incoming_call = Входящий звонок label_introduction_description = Пароль не задан. Доступ к аккаунту без пароля сохраняется в течении одного года с момента создания аккаунта или пока: @@ -692,6 +695,7 @@ label_subtitle_participants = участников label_tab_chats = Чаты label_tab_contacts = Контакты label_tab_menu = Меню +label_timeline_style = Стиль метки времени label_transition_count = Переходов: {$count} label_typing = Печатает label_unconfirmed = Неподтвержденный diff --git a/lib/domain/model/application_settings.dart b/lib/domain/model/application_settings.dart index 5bf55495489..1693b4017e2 100644 --- a/lib/domain/model/application_settings.dart +++ b/lib/domain/model/application_settings.dart @@ -34,6 +34,7 @@ class ApplicationSettings extends HiveObject { this.showDragAndDropButtonsHint = false, this.sortContactsByName = true, this.loadImages = true, + this.timelineEnabled = false, }); /// Indicator whether [OngoingCall]s are preferred to be displayed in the @@ -75,4 +76,9 @@ class ApplicationSettings extends HiveObject { /// Indicator whether [ImageAttachment]s should be loaded automatically. @HiveField(8) bool loadImages; + + /// Indicator whether [ChatItem.at] labels should be displayed as a timeline + /// in a [Chat]. + @HiveField(9) + bool timelineEnabled; } diff --git a/lib/domain/repository/settings.dart b/lib/domain/repository/settings.dart index 200cde4387e..c8492f798df 100644 --- a/lib/domain/repository/settings.dart +++ b/lib/domain/repository/settings.dart @@ -84,4 +84,7 @@ abstract class AbstractSettingsRepository { /// Returns the [Rect] preferences of an [OngoingCall] happening in the /// specified [Chat]. Rect? getCallRect(ChatId id); + + /// Sets the [ApplicationSettings.timelineEnabled] value. + Future setTimelineEnabled(bool enabled); } diff --git a/lib/provider/hive/application_settings.dart b/lib/provider/hive/application_settings.dart index 11e64ffd108..ab8eec5aa17 100644 --- a/lib/provider/hive/application_settings.dart +++ b/lib/provider/hive/application_settings.dart @@ -92,4 +92,11 @@ class ApplicationSettingsHiveProvider 0, (box.get(0) ?? ApplicationSettings())..loadImages = enabled, ); + + /// Stores a new [enabled] value of [ApplicationSettings.timelineEnabled] + /// to [Hive]. + Future setTimelineEnabled(bool enabled) => putSafe( + 0, + (box.get(0) ?? ApplicationSettings())..timelineEnabled = enabled, + ); } diff --git a/lib/routes.dart b/lib/routes.dart index 14e73370e0c..552fa750e63 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -101,6 +101,7 @@ enum ProfileTab { signing, link, background, + chats, calls, media, notifications, diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index e4fd694c753..4c391ecfb7c 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -1223,6 +1223,8 @@ class AwaitableTimer { _timer = Timer(d, () async { try { _completer.complete(await callback()); + } on StateError { + // No-op, as [Future] is allowed to be completed. } catch (e, stackTrace) { _completer.completeError(e, stackTrace); } diff --git a/lib/store/settings.dart b/lib/store/settings.dart index 649e59ad850..7f046a9f196 100644 --- a/lib/store/settings.dart +++ b/lib/store/settings.dart @@ -150,6 +150,10 @@ class SettingsRepository extends DisposableInterface @override Rect? getCallRect(ChatId id) => _callRectLocal.get(id); + @override + Future setTimelineEnabled(bool enabled) => + _settingsLocal.setTimelineEnabled(enabled); + /// Initializes [MediaSettingsHiveProvider.boxEvents] subscription. Future _initMediaSubscription() async { _mediaSubscription = StreamIterator(_mediaLocal.boxEvents); diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 16d4a7196bd..2841fc683c9 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -282,24 +282,26 @@ class _ChatViewState extends State ], ), body: Listener( - onPointerSignal: (s) { - if (s is PointerScrollEvent) { - if ((s.scrollDelta.dy.abs() < 3 && - s.scrollDelta.dx.abs() > 3) || - c.isHorizontalScroll.value) { - double value = - _animation.value + s.scrollDelta.dx / 100; - _animation.value = value.clamp(0, 1); - - if (_animation.value == 0 || - _animation.value == 1) { - _resetHorizontalScroll(c, 10.milliseconds); - } else { - _resetHorizontalScroll(c); + onPointerSignal: c.settings.value?.timelineEnabled == true + ? (s) { + if (s is PointerScrollEvent) { + if ((s.scrollDelta.dy.abs() < 3 && + s.scrollDelta.dx.abs() > 3) || + c.isHorizontalScroll.value) { + double value = + _animation.value + s.scrollDelta.dx / 100; + _animation.value = value.clamp(0, 1); + + if (_animation.value == 0 || + _animation.value == 1) { + _resetHorizontalScroll(c, 10.milliseconds); + } else { + _resetHorizontalScroll(c); + } + } + } } - } - } - }, + : null, onPointerPanZoomUpdate: (s) { if (c.scrollOffset.dx.abs() < 7 && c.scrollOffset.dy.abs() < 7) { @@ -323,7 +325,8 @@ class _ChatViewState extends State child: RawGestureDetector( behavior: HitTestBehavior.translucent, gestures: { - if (c.isSelecting.isFalse) + if (c.settings.value?.timelineEnabled == true && + c.isSelecting.isFalse) AllowMultipleHorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers< AllowMultipleHorizontalDragGestureRecognizer>( @@ -613,6 +616,7 @@ class _ChatViewState extends State user: u.data, getUser: c.getUser, animation: _animation, + timestamp: c.settings.value?.timelineEnabled != true, onHide: () => c.hideChatItem(e.value), onDelete: () => c.deleteMessage(e.value), onReply: () { @@ -622,9 +626,13 @@ class _ChatViewState extends State c.send.replied.insert(0, e.value); } }, - onCopy: c.selection.value?.plainText.isNotEmpty == true - ? (_) => c.copyText(c.selection.value!.plainText) - : c.copyText, + onCopy: (text) { + if (c.selection.value?.plainText.isNotEmpty == true) { + c.copyText(c.selection.value!.plainText); + } else { + c.copyText(text); + } + }, onRepliedTap: (q) async { if (q.original != null) { await c.animateTo(q.original!.id); @@ -665,6 +673,7 @@ class _ChatViewState extends State user: u.data, getUser: c.getUser, animation: _animation, + timestamp: c.settings.value?.timelineEnabled != true, onHide: () async { final List futures = []; @@ -714,9 +723,13 @@ class _ChatViewState extends State } } }, - onCopy: c.selection.value?.plainText.isNotEmpty == true - ? (_) => c.copyText(c.selection.value!.plainText) - : c.copyText, + onCopy: (text) { + if (c.selection.value?.plainText.isNotEmpty == true) { + c.copyText(c.selection.value!.plainText); + } else { + c.copyText(text); + } + }, onGallery: c.calculateGallery, onEdit: () => c.editMessage(element.note.value!.value), onDrag: (d) => c.isItemDragged.value = d, diff --git a/lib/ui/page/home/page/chat/widget/chat_forward.dart b/lib/ui/page/home/page/chat/widget/chat_forward.dart index f9389883a8e..9a62333a75d 100644 --- a/lib/ui/page/home/page/chat/widget/chat_forward.dart +++ b/lib/ui/page/home/page/chat/widget/chat_forward.dart @@ -54,6 +54,7 @@ import '/util/platform_utils.dart'; import 'animated_offset.dart'; import 'chat_item.dart'; import 'message_info/view.dart'; +import 'message_timestamp.dart'; import 'selection_text.dart'; import 'swipeable_status.dart'; @@ -69,8 +70,9 @@ class ChatForwardWidget extends StatefulWidget { this.reads = const [], this.loadImages = true, this.user, - this.getUser, this.animation, + this.timestamp = true, + this.getUser, this.onHide, this.onDelete, this.onReply, @@ -93,17 +95,11 @@ class ChatForwardWidget extends StatefulWidget { /// [ChatMessage] attached to these [forwards] as a note. final Rx?> note; - /// [UserId] of the authenticated [MyUser]. - final UserId me; - /// [UserId] of the [user] who posted these [forwards]. final UserId authorId; - /// Optional animation controlling a [SwipeableStatus]. - final AnimationController? animation; - - /// [User] posted these [forwards]. - final RxUser? user; + /// [UserId] of the authenticated [MyUser]. + final UserId me; /// [LastChatRead] to display under this [ChatItem]. final Iterable reads; @@ -112,6 +108,16 @@ class ChatForwardWidget extends StatefulWidget { /// fetched as soon as they are displayed, if any. final bool loadImages; + /// [User] posted these [forwards]. + final RxUser? user; + + /// Optional animation controlling a [SwipeableStatus]. + final AnimationController? animation; + + /// Indicator whether a [ChatItem.at] should be displayed within this + /// [ChatForwardWidget]. + final bool timestamp; + /// Callback, called when a [RxUser] identified by the provided [UserId] is /// required. final Future Function(UserId userId)? getUser; @@ -318,7 +324,12 @@ class _ChatForwardWidgetState extends State { ? const Radius.circular(15) : Radius.zero, ), - child: _forwardedMessage(e, menu), + child: _forwardedMessage( + e, + menu, + timestamp: widget.timestamp && + i == widget.forwards.length - 1, + ), ), ), ], @@ -334,7 +345,11 @@ class _ChatForwardWidgetState extends State { } /// Returns a visual representation of the provided [forward]. - Widget _forwardedMessage(Rx forward, bool menu) { + Widget _forwardedMessage( + Rx forward, + bool menu, { + bool timestamp = false, + }) { return Obx(() { ChatForward msg = forward.value as ChatForward; ChatItemQuote quote = msg.quote; @@ -366,15 +381,17 @@ class _ChatForwardWidgetState extends State { opacity: _isRead || !_fromMe ? 1 : 0.55, child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Column( - children: files - .map( - (e) => ChatItemWidget.fileAttachment( - e, - onFileTap: (a) => widget.onFileTap?.call(msg, a), - ), - ) - .toList(), + child: SelectionContainer.disabled( + child: Column( + children: files + .map( + (e) => ChatItemWidget.fileAttachment( + e, + onFileTap: (a) => widget.onFileTap?.call(msg, a), + ), + ) + .toList(), + ), ), ), ), @@ -514,64 +531,92 @@ class _ChatForwardWidgetState extends State { (snapshot.data?.user.value.num.val.sum() ?? 3) % AvatarWidget.colors.length]; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, + return Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - const SizedBox(width: 12), - Flexible( - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(width: 2, color: color), - ), - ), - margin: const EdgeInsets.fromLTRB(0, 8, 12, 8), - padding: const EdgeInsets.only(left: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 12), + Flexible( + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(width: 2, color: color), + ), + ), + margin: const EdgeInsets.fromLTRB(0, 8, 12, 8), + padding: const EdgeInsets.only(left: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Transform.scale( - scaleX: -1, - child: - Icon(Icons.reply, size: 17, color: color), + Row( + children: [ + Transform.scale( + scaleX: -1, + child: Icon( + Icons.reply, + size: 17, + color: color, + ), + ), + const SizedBox(width: 6), + Flexible( + child: SelectionText( + snapshot.data?.user.value.name?.val ?? + snapshot.data?.user.value.num.val ?? + 'dot'.l10n * 3, + selectable: + PlatformUtils.isDesktop || menu, + onChanged: (a) => _selection = a, + onSelecting: widget.onSelecting, + style: style.boldBody + .copyWith(color: color), + ), + ), + ], ), - const SizedBox(width: 6), - Flexible( - child: SelectionText( - snapshot.data?.user.value.name?.val ?? - snapshot.data?.user.value.num.val ?? - 'dot'.l10n * 3, - selectable: PlatformUtils.isDesktop || menu, - onChanged: (a) => _selection = a, - onSelecting: widget.onSelecting, - style: - style.boldBody.copyWith(color: color), + if (content != null) ...[ + const SizedBox(height: 2), + content, + ], + if (additional.isNotEmpty) ...[ + const SizedBox(height: 4), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + msg.authorId == widget.me + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: additional, ), - ), + ], ], ), - if (content != null) ...[ - const SizedBox(height: 2), - content, - ], - if (additional.isNotEmpty) ...[ - const SizedBox(height: 4), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: msg.authorId == widget.me - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: additional, - ), - ], - ], + ), ), + ], + ), + if (timestamp) + Padding( + padding: const EdgeInsets.only(right: 8, bottom: 4), + child: Obx(() { + final bool isMonolog = + widget.chat.value?.isMonolog == true; + + return MessageTimestamp( + at: forward.value.at, + status: _fromMe ? forward.value.status.value : null, + read: _isRead || isMonolog, + delivered: widget.chat.value?.lastDelivery + .isBefore(forward.value.at) == + false || + isMonolog, + ); + }), ), - ) ], ); }, @@ -656,15 +701,17 @@ class _ChatForwardWidgetState extends State { opacity: _isRead || !_fromMe ? 1 : 0.55, child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Column( - children: files - .map( - (e) => ChatItemWidget.fileAttachment( - e, - onFileTap: (a) => widget.onFileTap?.call(item, a), - ), - ) - .toList(), + child: SelectionContainer.disabled( + child: Column( + children: files + .map( + (e) => ChatItemWidget.fileAttachment( + e, + onFileTap: (a) => widget.onFileTap?.call(item, a), + ), + ) + .toList(), + ), ), ), ), diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 81611d3f92e..e8fc1c88cb6 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -63,6 +63,7 @@ import 'animated_offset.dart'; import 'data_attachment.dart'; import 'media_attachment.dart'; import 'message_info/view.dart'; +import 'message_timestamp.dart'; import 'selection_text.dart'; import 'swipeable_status.dart'; @@ -78,8 +79,9 @@ class ChatItemWidget extends StatefulWidget { this.margin = const EdgeInsets.fromLTRB(0, 6, 0, 6), this.reads = const [], this.loadImages = true, - this.getUser, this.animation, + this.timestamp = true, + this.getUser, this.onHide, this.onDelete, this.onReply, @@ -119,6 +121,13 @@ class ChatItemWidget extends StatefulWidget { /// fetched as soon as they are displayed, if any. final bool loadImages; + /// Optional animation that controls a [SwipeableStatus]. + final AnimationController? animation; + + /// Indicator whether a [ChatItem.at] should be displayed within this + /// [ChatItemWidget]. + final bool timestamp; + /// Callback, called when a [RxUser] identified by the provided [UserId] is /// required. final Future Function(UserId userId)? getUser; @@ -138,9 +147,6 @@ class ChatItemWidget extends StatefulWidget { /// Callback, called when a copy action of this [ChatItem] is triggered. final void Function(String text)? onCopy; - /// Optional animation that controls a [SwipeableStatus]. - final AnimationController? animation; - /// Callback, called when a gallery list is required. /// /// If not specified, then only media in this [item] will be in a gallery. @@ -844,6 +850,10 @@ class _ChatItemWidgetState extends State { } } + // Indicator whether the [_timestamp] should be displayed in a bubble above + // the [ChatMessage] (e.g. if there's an [ImageAttachment]). + final bool timeInBubble = media.isNotEmpty; + return _rounded( context, (menu) { @@ -887,7 +897,13 @@ class _ChatItemWidgetState extends State { child: WidgetButton( onPressed: menu ? null : () => widget.onRepliedTap?.call(e), - child: _repliedMessage(e), + child: _repliedMessage( + e, + timestamp: widget.timestamp && + i == msg.repliesTo.length - 1 && + _text == null && + msg.attachments.isEmpty, + ), ), ), ), @@ -931,8 +947,16 @@ class _ChatItemWidgetState extends State { files.isEmpty ? 10 : 0, ), child: SelectionText.rich( - _text!, key: Key('Text_${widget.item.value.id}'), + TextSpan( + children: [ + _text!, + if (widget.timestamp && files.isEmpty && !timeInBubble) + WidgetSpan( + child: Opacity(opacity: 0, child: _timestamp(msg)), + ), + ], + ), selectable: PlatformUtils.isDesktop || menu, onSelecting: widget.onSelecting, onChanged: (a) => _selection = a, @@ -946,15 +970,19 @@ class _ChatItemWidgetState extends State { opacity: _isRead || !_fromMe ? 1 : 0.55, child: Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Column( - children: files - .map( - (e) => ChatItemWidget.fileAttachment( + child: SelectionContainer.disabled( + child: Column( + children: [ + ...files.mapIndexed( + (i, e) => ChatItemWidget.fileAttachment( e, onFileTap: widget.onFileTap, ), - ) - .toList(), + ), + if (widget.timestamp && !timeInBubble) + Opacity(opacity: 0, child: _timestamp(msg)), + ], + ), ), ), ), @@ -1022,27 +1050,47 @@ class _ChatItemWidgetState extends State { return Container( padding: widget.margin.add(const EdgeInsets.fromLTRB(5, 0, 2, 0)), - child: IntrinsicWidth( - child: AnimatedContainer( - duration: const Duration(milliseconds: 500), - decoration: BoxDecoration( - color: _fromMe - ? _isRead - ? style.readMessageColor - : style.unreadMessageColor - : style.messageColor, - borderRadius: BorderRadius.circular(15), - border: _fromMe - ? _isRead - ? style.secondaryBorder - : Border.all(color: const Color(0xFFDAEDFF), width: 0.5) - : style.primaryBorder, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + child: Stack( + children: [ + IntrinsicWidth( + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + color: _fromMe + ? _isRead + ? style.readMessageColor + : style.unreadMessageColor + : style.messageColor, + borderRadius: BorderRadius.circular(15), + border: _fromMe + ? _isRead + ? style.secondaryBorder + : Border.all( + color: const Color(0xFFDAEDFF), width: 0.5) + : style.primaryBorder, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), ), - ), + if (widget.timestamp) + Positioned( + right: timeInBubble ? 6 : 8, + bottom: 4, + child: timeInBubble + ? Container( + padding: const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: _timestamp(msg), + ) + : _timestamp(msg), + ) + ], ), ); }, @@ -1099,80 +1147,110 @@ class _ChatItemWidgetState extends State { : AvatarWidget.colors[(widget.user?.user.value.num.val.sum() ?? 3) % AvatarWidget.colors.length]; + final Widget call = Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.black.withOpacity(0.03), + ), + padding: const EdgeInsets.fromLTRB(6, 8, 8, 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 12, 0), + child: message.withVideo + ? SvgLoader.asset( + 'assets/icons/call_video${isMissed && !_fromMe ? '_red' : ''}.svg', + height: 13 * 1.4, + ) + : SvgLoader.asset( + 'assets/icons/call_audio${isMissed && !_fromMe ? '_red' : ''}.svg', + height: 15 * 1.4, + ), + ), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style.boldBody, + ), + ), + if (time != null) ...[ + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 1), + child: Text( + time, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + ], + ), + ); + final Widget child = AnimatedOpacity( duration: const Duration(milliseconds: 500), opacity: _isRead || !_fromMe ? 1 : 0.55, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!_fromMe && widget.chat.value?.isGroup == true && widget.avatar) - Padding( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), - child: Text( - widget.user?.user.value.name?.val ?? - widget.user?.user.value.num.val ?? - 'dot'.l10n * 3, - style: style.boldBody.copyWith(color: color), - ), - ), - const SizedBox(height: 4), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.black.withOpacity(0.03), - ), - padding: const EdgeInsets.fromLTRB(6, 8, 8, 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_fromMe && + widget.chat.value?.isGroup == true && + widget.avatar) Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 12, 0), - child: message.withVideo - ? SvgLoader.asset( - 'assets/icons/call_video${isMissed && !_fromMe ? '_red' : ''}.svg', - height: 13 * 1.4, - ) - : SvgLoader.asset( - 'assets/icons/call_audio${isMissed && !_fromMe ? '_red' : ''}.svg', - height: 15 * 1.4, - ), + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + child: Text( + widget.user?.user.value.name?.val ?? + widget.user?.user.value.num.val ?? + 'dot'.l10n * 3, + style: style.boldBody.copyWith(color: color), + ), ), - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: style.boldBody, - ), - ), - if (time != null) ...[ - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 1), - child: Text( - time, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + WidgetSpan(child: call), + if (widget.timestamp) + WidgetSpan( + child: Opacity( + opacity: 0, + child: Padding( + padding: const EdgeInsets.only(left: 4), + child: _timestamp(widget.item.value), ), ), - ], - ], - ), + ), + ], ), - const SizedBox(width: 8), - ], - ), + ), + ], ), - ], - ), + ), + if (widget.timestamp) + Positioned( + right: 8, + bottom: 4, + child: _timestamp(widget.item.value), + ) + ], ), ); @@ -1205,7 +1283,7 @@ class _ChatItemWidgetState extends State { } /// Renders the provided [item] as a replied message. - Widget _repliedMessage(ChatItemQuote item) { + Widget _repliedMessage(ChatItemQuote item, {bool timestamp = false}) { Style style = Theme.of(context).extension