fix: salas removidas de vez, paginação no mural, mídia em largura total e teclado nos comentários

This commit is contained in:
2026-09-14 17:15:52 -03:00
parent 8927e80cce
commit 9d69e833bd
8 changed files with 234 additions and 134 deletions

View File

@@ -668,6 +668,7 @@ class Message extends StatelessWidget {
timeline: timeline, timeline: timeline,
selected: selected, selected: selected,
bigEmojis: bigEmojis, bigEmojis: bigEmojis,
fullWidthCard: card,
), ),
], ],
), ),

View File

@@ -33,6 +33,7 @@ class MessageContent extends StatelessWidget {
final Timeline timeline; final Timeline timeline;
final bool selected; final bool selected;
final Set<String> bigEmojis; final Set<String> bigEmojis;
final bool fullWidthCard;
const MessageContent( const MessageContent(
this.event, { this.event, {
@@ -44,6 +45,7 @@ class MessageContent extends StatelessWidget {
required this.borderRadius, required this.borderRadius,
required this.selected, required this.selected,
required this.bigEmojis, required this.bigEmojis,
this.fullWidthCard = false,
}); });
@override @override
@@ -67,13 +69,40 @@ class MessageContent extends StatelessWidget {
final h = event.content final h = event.content
.tryGetMap<String, Object?>('info') .tryGetMap<String, Object?>('info')
?.tryGet<int>('h'); ?.tryGet<int>('h');
var width = maxSize; var width = fullWidthCard && event.messageType == MessageTypes.Image
? double.infinity
: maxSize;
var height = maxSize; var height = maxSize;
var fit = event.messageType == MessageTypes.Sticker var fit = event.messageType == MessageTypes.Sticker
? BoxFit.contain ? BoxFit.contain
: BoxFit.cover; : BoxFit.cover;
if (w != null && h != null) { if (w != null && h != null) {
fit = BoxFit.contain; 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) { if (w > h) {
width = maxSize; width = maxSize;
height = max(32, maxSize * (h / w)); height = max(32, maxSize * (h / w));
@@ -124,6 +153,7 @@ class MessageContent extends StatelessWidget {
textColor: textColor, textColor: textColor,
linkColor: linkColor, linkColor: linkColor,
timeline: timeline, timeline: timeline,
expand: fullWidthCard,
); );
case MessageTypes.File: case MessageTypes.File:
return MessageDownloadContent( return MessageDownloadContent(

View File

@@ -24,12 +24,14 @@ class EventVideoPlayer extends StatelessWidget {
final Timeline? timeline; final Timeline? timeline;
final Color? textColor; final Color? textColor;
final Color? linkColor; final Color? linkColor;
final bool expand;
const EventVideoPlayer( const EventVideoPlayer(
this.event, { this.event, {
this.timeline, this.timeline,
this.textColor, this.textColor,
this.linkColor, this.linkColor,
this.expand = false,
super.key, super.key,
}); });
@@ -49,36 +51,20 @@ class EventVideoPlayer extends StatelessWidget {
final infoMap = event.content.tryGetMap<String, Object?>('info'); final infoMap = event.content.tryGetMap<String, Object?>('info');
final videoWidth = infoMap?.tryGet<int>('w') ?? maxDimension; final videoWidth = infoMap?.tryGet<int>('w') ?? maxDimension;
final videoHeight = infoMap?.tryGet<int>('h') ?? maxDimension; final videoHeight = infoMap?.tryGet<int>('h') ?? maxDimension;
final hasDimensions = videoWidth > 0 && videoHeight > 0;
final modifier = max(videoWidth, videoHeight) / maxDimension; final modifier = max(videoWidth, videoHeight) / maxDimension;
final width = videoWidth / modifier; final width = expand ? double.infinity : videoWidth / modifier;
final height = videoHeight / modifier; final height = expand
? (hasDimensions ? double.infinity : maxDimension)
: videoHeight / modifier;
final durationInt = infoMap?.tryGet<int>('duration'); final durationInt = infoMap?.tryGet<int>('duration');
final duration = durationInt == null final duration = durationInt == null
? null ? null
: Duration(milliseconds: durationInt); : Duration(milliseconds: durationInt);
return Column( final player = SizedBox(
mainAxisSize: .min,
spacing: 8,
children: [
Material(
color: Colors.black,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: InkWell(
onTap: () => supportsVideoPlayer
? showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
),
)
: event.saveFile(context),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: SizedBox(
width: width, width: width,
height: height, height: height,
child: Hero( child: Hero(
@@ -131,7 +117,33 @@ class EventVideoPlayer extends StatelessWidget {
], ],
), ),
), ),
);
return Column(
mainAxisSize: .min,
spacing: 8,
children: [
Material(
color: Colors.black,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: InkWell(
onTap: () => supportsVideoPlayer
? showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
), ),
)
: event.saveFile(context),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: expand && hasDimensions
? AspectRatio(
aspectRatio: videoWidth / videoHeight,
child: player,
)
: player,
), ),
), ),
if (fileDescription != null && textColor != null && linkColor != null) if (fileDescription != null && textColor != null && linkColor != null)

View File

@@ -413,8 +413,9 @@ class ChatListController extends State<ChatList>
scrollController.addListener(_onScroll); scrollController.addListener(_onScroll);
_waitForFirstSync(); _waitForFirstSync();
_backfillSubscription = _backfillSubscription = _clientStream.stream.listen(
_clientStream.stream.listen((_) => backfillRoomTypeMarkers()); (_) => backfillRoomTypeMarkers(),
);
if (PlatformInfos.isMobile) { if (PlatformInfos.isMobile) {
_callEventSubscription = FlutterCallkitIncoming.onEvent.listen( _callEventSubscription = FlutterCallkitIncoming.onEvent.listen(
_onCallEvent, _onCallEvent,
@@ -910,30 +911,7 @@ class ChatListController extends State<ChatList>
bool waitForFirstSync = false; bool waitForFirstSync = false;
/// Leaves the room locally and removes it from the local database. /// Leaves the room locally and removes it from the local database.
Future<void> leaveRoom(Room room) async { Future<void> leaveRoom(Room room) => safeLeave(room);
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);
}
/// Loads the shared [roomTypeStateEvent] marker from the synced room state /// Loads the shared [roomTypeStateEvent] marker from the synced room state
/// (from the local database for partially loaded rooms) and writes it for /// (from the local database for partially loaded rooms) and writes it for

View File

@@ -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/pages/chat_list/unread_bubble.dart';
import 'package:tatchat/utils/localized_exception_extension.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_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/stream_extension.dart';
import 'package:tatchat/utils/string_color.dart'; import 'package:tatchat/utils/string_color.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
@@ -198,7 +199,11 @@ class _SpaceViewState extends State<SpaceView> {
final success = await showFutureLoadingDialog( final success = await showFutureLoadingDialog(
context: context, context: context,
future: () async => await space?.leave(), future: () async {
final room = space;
if (room == null) return;
await safeLeave(room);
},
); );
if (!mounted) return; if (!mounted) return;
if (success.error != null) return; if (success.error != null) return;
@@ -361,7 +366,7 @@ class _SpaceViewState extends State<SpaceView> {
case SpaceChildAction.leave: case SpaceChildAction.leave:
await showFutureLoadingDialog( await showFutureLoadingDialog(
context: context, context: context,
future: () => room!.leave(), future: () => safeLeave(room!),
); );
} }
} }

View File

@@ -85,19 +85,53 @@ class _MuralChatViewState extends State<MuralChatView> {
.toList(); .toList();
bool _descriptionExpanded = false; bool _descriptionExpanded = false;
bool _loadingHistory = false;
bool _historyEnded = false;
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_getTimeline(); _getTimeline();
_scrollController.addListener(_onScroll);
} }
@override @override
void dispose() { void dispose() {
_scrollController.dispose();
timeline?.cancelSubscriptions(); timeline?.cancelSubscriptions();
super.dispose(); super.dispose();
} }
void _onScroll() {
if (_historyEnded) return;
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _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() { void updateView() {
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
@@ -105,7 +139,12 @@ class _MuralChatViewState extends State<MuralChatView> {
Future<void> _getTimeline() async { Future<void> _getTimeline() async {
timeline?.cancelSubscriptions(); 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; if (!mounted) return;
setState(() {}); setState(() {});
} }
@@ -220,8 +259,12 @@ class _MuralChatViewState extends State<MuralChatView> {
useSafeArea: true, useSafeArea: true,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: theme.colorScheme.surface, backgroundColor: theme.colorScheme.surface,
builder: (context) => FractionallySizedBox( builder: (context) => ConstrainedBox(
heightFactor: 0.92, constraints: BoxConstraints(
maxHeight:
MediaQuery.viewInsetsOf(context).bottom +
(MediaQuery.sizeOf(context).height * 0.92),
),
child: _PostViewer( child: _PostViewer(
key: Key('post_viewer_${event.eventId}'), key: Key('post_viewer_${event.eventId}'),
event: event, event: event,
@@ -294,6 +337,7 @@ class _MuralChatViewState extends State<MuralChatView> {
), ),
) )
: GridView.builder( : GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(2), padding: const EdgeInsets.all(2),
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( const SliverGridDelegateWithFixedCrossAxisCount(
@@ -301,8 +345,17 @@ class _MuralChatViewState extends State<MuralChatView> {
mainAxisSpacing: 2, mainAxisSpacing: 2,
crossAxisSpacing: 2, crossAxisSpacing: 2,
), ),
itemCount: events.length, itemCount: events.length + (_loadingHistory ? 1 : 0),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= events.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final event = events[i]; final event = events[i];
return _MediaCell( return _MediaCell(
event: event, event: event,
@@ -714,9 +767,8 @@ class _PostViewerState extends State<_PostViewer> {
Future<void> _forwardPost() async { Future<void> _forwardPost() async {
await showScaffoldDialog( await showScaffoldDialog(
context: context, context: context,
builder: (context) => ShareScaffoldDialog( builder: (context) =>
items: [ContentShareItem(event.content.copy())], ShareScaffoldDialog(items: [ContentShareItem(event.content.copy())]),
),
); );
} }
@@ -780,9 +832,7 @@ class _PostViewerState extends State<_PostViewer> {
e.relationshipEventId == event.eventId, e.relationshipEventId == event.eventId,
) )
.toList(); .toList();
allComments.sort( allComments.sort((a, b) => a.originServerTs.compareTo(b.originServerTs));
(a, b) => a.originServerTs.compareTo(b.originServerTs),
);
final nodeMap = <String, _CommentNode>{}; final nodeMap = <String, _CommentNode>{};
final roots = <_CommentNode>[]; final roots = <_CommentNode>[];
@@ -799,8 +849,7 @@ class _PostViewerState extends State<_PostViewer> {
nodeMap.containsKey(current)) { nodeMap.containsKey(current)) {
if (!visited.add(current)) break; if (!visited.add(current)) break;
final node = nodeMap[current]!; final node = nodeMap[current]!;
final parentId = final parentId = node.event.inReplyToEventId(includingFallback: false);
node.event.inReplyToEventId(includingFallback: false);
if (parentId == null || if (parentId == null ||
parentId == event.eventId || parentId == event.eventId ||
!nodeMap.containsKey(parentId)) { !nodeMap.containsKey(parentId)) {
@@ -1226,20 +1275,21 @@ class _PostViewerState extends State<_PostViewer> {
), ),
) )
else else
...commentTree.map((node) => _CommentTile( ...commentTree.map(
(node) => _CommentTile(
node: node, node: node,
depth: 0, depth: 0,
timeline: widget.timeline, timeline: widget.timeline,
currentUserId: widget.room.client.userID, currentUserId: widget.room.client.userID,
onReply: (comment) => setState( onReply: (comment) =>
() => _replyingTo = comment, setState(() => _replyingTo = comment),
),
onToggleReaction: _toggleCommentReaction, onToggleReaction: _toggleCommentReaction,
onShowReactionPicker: _showCommentReactionPicker, onShowReactionPicker: _showCommentReactionPicker,
onOpenProfile: _openProfile, onOpenProfile: _openProfile,
onDelete: _deleteComment, onDelete: _deleteComment,
onReport: _reportComment, onReport: _reportComment,
)), ),
),
], ],
), ),
), ),
@@ -1458,8 +1508,7 @@ class _CommentTile extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
child: GestureDetector( child: GestureDetector(
onTap: () => onTap: () => onToggleReaction(comment, entry.key),
onToggleReaction(comment, entry.key),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 6,
@@ -1474,8 +1523,7 @@ class _CommentTile extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: isMyReaction border: isMyReaction
? Border.all( ? Border.all(
color: color: theme.colorScheme.primary,
theme.colorScheme.primary,
width: 1, width: 1,
) )
: null, : null,

View File

@@ -12,6 +12,31 @@ import 'package:tatchat/config/app_config.dart';
/// different accounts. /// different accounts.
const String roomTypeStateEvent = 'tatuchat.room_type'; 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<void> 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 { extension RoomTypeExtension on Room {
String? get roomType => String? get roomType =>
getState(roomTypeStateEvent)?.content.tryGet<String>('type'); getState(roomTypeStateEvent)?.content.tryGet<String>('type');

View File

@@ -6,6 +6,7 @@
import 'dart:async'; import 'dart:async';
import 'package:tatchat/l10n/l10n.dart'; 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/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -69,7 +70,7 @@ class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
if (!context.mounted) return; if (!context.mounted) return;
final result = await showFutureLoadingDialog( final result = await showFutureLoadingDialog(
context: context, context: context,
future: () => widget.room.leave(), future: () => safeLeave(widget.room),
); );
if (result.error == null) { if (result.error == null) {
router.go('/rooms'); router.go('/rooms');