diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 7dca1c4..7dd3d65 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -668,6 +668,7 @@ class Message extends StatelessWidget { timeline: timeline, selected: selected, bigEmojis: bigEmojis, + fullWidthCard: card, ), ], ), diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 3a6c58c..3eb2ef2 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -33,6 +33,7 @@ class MessageContent extends StatelessWidget { final Timeline timeline; final bool selected; final Set bigEmojis; + final bool fullWidthCard; const MessageContent( this.event, { @@ -44,6 +45,7 @@ class MessageContent extends StatelessWidget { required this.borderRadius, required this.selected, required this.bigEmojis, + this.fullWidthCard = false, }); @override @@ -67,13 +69,40 @@ class MessageContent extends StatelessWidget { final h = event.content .tryGetMap('info') ?.tryGet('h'); - var width = maxSize; + var width = fullWidthCard && event.messageType == MessageTypes.Image + ? double.infinity + : maxSize; var height = maxSize; var fit = event.messageType == MessageTypes.Sticker ? BoxFit.contain : BoxFit.cover; if (w != null && h != null) { fit = BoxFit.contain; + if (fullWidthCard && + event.messageType == MessageTypes.Image && + w > 0 && + h > 0) { + return AspectRatio( + aspectRatio: w / h, + child: ImageBubble( + event, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + borderRadius: borderRadius, + timeline: timeline, + textColor: textColor, + onTap: () => showDialog( + context: context, + builder: (_) => ImageViewer( + event, + timeline: timeline, + outerContext: context, + ), + ), + ), + ); + } if (w > h) { width = maxSize; height = max(32, maxSize * (h / w)); @@ -124,6 +153,7 @@ class MessageContent extends StatelessWidget { textColor: textColor, linkColor: linkColor, timeline: timeline, + expand: fullWidthCard, ); case MessageTypes.File: return MessageDownloadContent( diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index 51985dd..0f9f048 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -24,12 +24,14 @@ class EventVideoPlayer extends StatelessWidget { final Timeline? timeline; final Color? textColor; final Color? linkColor; + final bool expand; const EventVideoPlayer( this.event, { this.timeline, this.textColor, this.linkColor, + this.expand = false, super.key, }); @@ -49,16 +51,74 @@ class EventVideoPlayer extends StatelessWidget { final infoMap = event.content.tryGetMap('info'); final videoWidth = infoMap?.tryGet('w') ?? maxDimension; final videoHeight = infoMap?.tryGet('h') ?? maxDimension; + final hasDimensions = videoWidth > 0 && videoHeight > 0; final modifier = max(videoWidth, videoHeight) / maxDimension; - final width = videoWidth / modifier; - final height = videoHeight / modifier; + final width = expand ? double.infinity : videoWidth / modifier; + final height = expand + ? (hasDimensions ? double.infinity : maxDimension) + : videoHeight / modifier; final durationInt = infoMap?.tryGet('duration'); final duration = durationInt == null ? null : Duration(milliseconds: durationInt); + final player = SizedBox( + width: width, + height: height, + child: Hero( + tag: event.eventId, + child: Stack( + children: [ + if (event.hasThumbnail && + AppSettings.showThumbnailsInTimeline.value) + MxcImage( + event: event, + cacheKey: event.transactionId ?? event.eventId, + cacheName: event.room.id, + isThumbnail: true, + width: width, + height: height, + fit: BoxFit.cover, + placeholder: (context) => BlurHash( + blurhash: blurHash, + width: width, + height: height, + fit: BoxFit.cover, + ), + ) + else + BlurHash( + blurhash: blurHash, + width: width, + height: height, + fit: BoxFit.cover, + ), + Center( + child: CircleAvatar( + child: supportsVideoPlayer + ? const Icon(Icons.play_arrow_outlined) + : const Icon(Icons.file_download_outlined), + ), + ), + if (duration != null) + Positioned( + bottom: 8, + left: 16, + child: Text( + '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.white, + backgroundColor: Colors.black.withAlpha(32), + ), + ), + ), + ], + ), + ), + ); + return Column( mainAxisSize: .min, spacing: 8, @@ -78,60 +138,12 @@ class EventVideoPlayer extends StatelessWidget { ) : event.saveFile(context), borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: SizedBox( - width: width, - height: height, - child: Hero( - tag: event.eventId, - child: Stack( - children: [ - if (event.hasThumbnail && - AppSettings.showThumbnailsInTimeline.value) - MxcImage( - event: event, - cacheKey: event.transactionId ?? event.eventId, - cacheName: event.room.id, - isThumbnail: true, - width: width, - height: height, - fit: BoxFit.cover, - placeholder: (context) => BlurHash( - blurhash: blurHash, - width: width, - height: height, - fit: BoxFit.cover, - ), - ) - else - BlurHash( - blurhash: blurHash, - width: width, - height: height, - fit: BoxFit.cover, - ), - Center( - child: CircleAvatar( - child: supportsVideoPlayer - ? const Icon(Icons.play_arrow_outlined) - : const Icon(Icons.file_download_outlined), - ), - ), - if (duration != null) - Positioned( - bottom: 8, - left: 16, - child: Text( - '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}', - style: TextStyle( - color: Colors.white, - backgroundColor: Colors.black.withAlpha(32), - ), - ), - ), - ], - ), - ), - ), + child: expand && hasDimensions + ? AspectRatio( + aspectRatio: videoWidth / videoHeight, + child: player, + ) + : player, ), ), if (fileDescription != null && textColor != null && linkColor != null) diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 8bd5305..a254633 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -413,8 +413,9 @@ class ChatListController extends State scrollController.addListener(_onScroll); _waitForFirstSync(); - _backfillSubscription = - _clientStream.stream.listen((_) => backfillRoomTypeMarkers()); + _backfillSubscription = _clientStream.stream.listen( + (_) => backfillRoomTypeMarkers(), + ); if (PlatformInfos.isMobile) { _callEventSubscription = FlutterCallkitIncoming.onEvent.listen( _onCallEvent, @@ -910,30 +911,7 @@ class ChatListController extends State bool waitForFirstSync = false; /// Leaves the room locally and removes it from the local database. - Future leaveRoom(Room room) async { - try { - await room.leave(); - } on MatrixException catch (e, s) { - // If the room was already left on the server (e.g. a stale local copy - // reappeared after a restart) the leave request fails, but the user - // intent (room not being listed anymore) is already fulfilled. Clean up - // locally instead of showing an error. - if (!{ - MatrixError.M_FORBIDDEN, - MatrixError.M_NOT_FOUND, - MatrixError.M_UNKNOWN, - }.contains(e.error)) { - Logs().w('Failed to leave room ${room.id}', e, s); - rethrow; - } - Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s); - } - final client = room.client; - // Make sure the room does not reappear from the local database after an - // app restart, even if the leave sync did not run in time. - await client.database.forgetRoom(room.id); - client.rooms.remove(room); - } + Future leaveRoom(Room room) => safeLeave(room); /// Loads the shared [roomTypeStateEvent] marker from the synced room state /// (from the local database for partially loaded rooms) and writes it for diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 167b30e..3e5ebce 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -13,6 +13,7 @@ import 'package:tatchat/pages/chat_list/active_call_indicator.dart'; import 'package:tatchat/pages/chat_list/unread_bubble.dart'; import 'package:tatchat/utils/localized_exception_extension.dart'; import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart'; import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/string_color.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; @@ -198,7 +199,11 @@ class _SpaceViewState extends State { final success = await showFutureLoadingDialog( context: context, - future: () async => await space?.leave(), + future: () async { + final room = space; + if (room == null) return; + await safeLeave(room); + }, ); if (!mounted) return; if (success.error != null) return; @@ -361,7 +366,7 @@ class _SpaceViewState extends State { case SpaceChildAction.leave: await showFutureLoadingDialog( context: context, - future: () => room!.leave(), + future: () => safeLeave(room!), ); } } diff --git a/lib/pages/mural/mural_chat_page.dart b/lib/pages/mural/mural_chat_page.dart index d71b22d..8ebe257 100644 --- a/lib/pages/mural/mural_chat_page.dart +++ b/lib/pages/mural/mural_chat_page.dart @@ -85,19 +85,53 @@ class _MuralChatViewState extends State { .toList(); bool _descriptionExpanded = false; + bool _loadingHistory = false; + bool _historyEnded = false; + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); _getTimeline(); + _scrollController.addListener(_onScroll); } @override void dispose() { + _scrollController.dispose(); timeline?.cancelSubscriptions(); super.dispose(); } + void _onScroll() { + if (_historyEnded) return; + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + Future _loadMore() async { + final timeline = this.timeline; + if (timeline == null || _loadingHistory || timeline.isRequestingHistory) { + return; + } + _loadingHistory = true; + setState(() {}); + final eventCountBefore = timeline.events.length; + try { + await timeline.requestHistory(); + } catch (e, s) { + Logs().w('Failed to load more mural posts', e, s); + } finally { + if (timeline.events.length <= eventCountBefore) { + _historyEnded = true; + } + _loadingHistory = false; + if (mounted) setState(() {}); + } + } + void updateView() { if (!mounted) return; setState(() {}); @@ -105,7 +139,12 @@ class _MuralChatViewState extends State { Future _getTimeline() async { timeline?.cancelSubscriptions(); - timeline = await room.getTimeline(onUpdate: updateView); + _loadingHistory = false; + _historyEnded = false; + timeline = await room.getTimeline( + onUpdate: updateView, + limit: Room.defaultHistoryCount * 3, + ); if (!mounted) return; setState(() {}); } @@ -220,8 +259,12 @@ class _MuralChatViewState extends State { useSafeArea: true, isScrollControlled: true, backgroundColor: theme.colorScheme.surface, - builder: (context) => FractionallySizedBox( - heightFactor: 0.92, + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.viewInsetsOf(context).bottom + + (MediaQuery.sizeOf(context).height * 0.92), + ), child: _PostViewer( key: Key('post_viewer_${event.eventId}'), event: event, @@ -294,6 +337,7 @@ class _MuralChatViewState extends State { ), ) : GridView.builder( + controller: _scrollController, padding: const EdgeInsets.all(2), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( @@ -301,8 +345,17 @@ class _MuralChatViewState extends State { mainAxisSpacing: 2, crossAxisSpacing: 2, ), - itemCount: events.length, + itemCount: events.length + (_loadingHistory ? 1 : 0), itemBuilder: (context, i) { + if (i >= events.length) { + return const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } final event = events[i]; return _MediaCell( event: event, @@ -714,9 +767,8 @@ class _PostViewerState extends State<_PostViewer> { Future _forwardPost() async { await showScaffoldDialog( context: context, - builder: (context) => ShareScaffoldDialog( - items: [ContentShareItem(event.content.copy())], - ), + builder: (context) => + ShareScaffoldDialog(items: [ContentShareItem(event.content.copy())]), ); } @@ -780,9 +832,7 @@ class _PostViewerState extends State<_PostViewer> { e.relationshipEventId == event.eventId, ) .toList(); - allComments.sort( - (a, b) => a.originServerTs.compareTo(b.originServerTs), - ); + allComments.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); final nodeMap = {}; final roots = <_CommentNode>[]; @@ -799,8 +849,7 @@ class _PostViewerState extends State<_PostViewer> { nodeMap.containsKey(current)) { if (!visited.add(current)) break; final node = nodeMap[current]!; - final parentId = - node.event.inReplyToEventId(includingFallback: false); + final parentId = node.event.inReplyToEventId(includingFallback: false); if (parentId == null || parentId == event.eventId || !nodeMap.containsKey(parentId)) { @@ -1116,22 +1165,22 @@ class _PostViewerState extends State<_PostViewer> { child: isVideo ? EventVideoPlayer(event) : hasImageDimensions - ? AspectRatio( - aspectRatio: imgW / imgH, - child: MxcImage( - key: ValueKey(event.eventId), - event: event, - fit: BoxFit.cover, - isThumbnail: false, - ), - ) - : MxcImage( - key: ValueKey(event.eventId), - event: event, - width: double.infinity, - fit: BoxFit.contain, - isThumbnail: false, - ), + ? AspectRatio( + aspectRatio: imgW / imgH, + child: MxcImage( + key: ValueKey(event.eventId), + event: event, + fit: BoxFit.cover, + isThumbnail: false, + ), + ) + : MxcImage( + key: ValueKey(event.eventId), + event: event, + width: double.infinity, + fit: BoxFit.contain, + isThumbnail: false, + ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -1226,20 +1275,21 @@ class _PostViewerState extends State<_PostViewer> { ), ) else - ...commentTree.map((node) => _CommentTile( - node: node, - depth: 0, - timeline: widget.timeline, - currentUserId: widget.room.client.userID, - onReply: (comment) => setState( - () => _replyingTo = comment, - ), - onToggleReaction: _toggleCommentReaction, - onShowReactionPicker: _showCommentReactionPicker, - onOpenProfile: _openProfile, - onDelete: _deleteComment, - onReport: _reportComment, - )), + ...commentTree.map( + (node) => _CommentTile( + node: node, + depth: 0, + timeline: widget.timeline, + currentUserId: widget.room.client.userID, + onReply: (comment) => + setState(() => _replyingTo = comment), + onToggleReaction: _toggleCommentReaction, + onShowReactionPicker: _showCommentReactionPicker, + onOpenProfile: _openProfile, + onDelete: _deleteComment, + onReport: _reportComment, + ), + ), ], ), ), @@ -1458,8 +1508,7 @@ class _CommentTile extends StatelessWidget { return Padding( padding: const EdgeInsets.only(right: 4), child: GestureDetector( - onTap: () => - onToggleReaction(comment, entry.key), + onTap: () => onToggleReaction(comment, entry.key), child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -1469,13 +1518,12 @@ class _CommentTile extends StatelessWidget { color: isMyReaction ? theme.colorScheme.primaryContainer : theme - .colorScheme - .surfaceContainerHighest, + .colorScheme + .surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: isMyReaction ? Border.all( - color: - theme.colorScheme.primary, + color: theme.colorScheme.primary, width: 1, ) : null, diff --git a/lib/utils/matrix_sdk_extensions/room_extension.dart b/lib/utils/matrix_sdk_extensions/room_extension.dart index 6a7baa5..91e9d4e 100644 --- a/lib/utils/matrix_sdk_extensions/room_extension.dart +++ b/lib/utils/matrix_sdk_extensions/room_extension.dart @@ -12,6 +12,31 @@ import 'package:tatchat/config/app_config.dart'; /// different accounts. const String roomTypeStateEvent = 'tatuchat.room_type'; +/// Leaves the room and removes it from the local database so that it does not +/// reappear on the next sync or app restart. +Future safeLeave(Room room) async { + try { + await room.leave(); + } on MatrixException catch (e, s) { + // If the room was already left on the server (e.g. a stale local copy + // reappeared after a restart) the leave request fails, but the user + // intent (room not being listed anymore) is already fulfilled. Clean up + // locally instead of showing an error. + if (!{ + MatrixError.M_FORBIDDEN, + MatrixError.M_NOT_FOUND, + MatrixError.M_UNKNOWN, + }.contains(e.error)) { + Logs().w('Failed to leave room ${room.id}', e, s); + rethrow; + } + Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s); + } + final client = room.client; + await client.database.forgetRoom(room.id); + client.rooms.remove(room); +} + extension RoomTypeExtension on Room { String? get roomType => getState(roomTypeStateEvent)?.content.tryGet('type'); diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 08b29ab..acf8fce 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; @@ -69,7 +70,7 @@ class ChatSettingsPopupMenuState extends State { if (!context.mounted) return; final result = await showFutureLoadingDialog( context: context, - future: () => widget.room.leave(), + future: () => safeLeave(widget.room), ); if (result.error == null) { router.go('/rooms');