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,
selected: selected,
bigEmojis: bigEmojis,
fullWidthCard: card,
),
],
),

View File

@@ -33,6 +33,7 @@ class MessageContent extends StatelessWidget {
final Timeline timeline;
final bool selected;
final Set<String> 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<String, Object?>('info')
?.tryGet<int>('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(

View File

@@ -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<String, Object?>('info');
final videoWidth = infoMap?.tryGet<int>('w') ?? maxDimension;
final videoHeight = infoMap?.tryGet<int>('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<int>('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)

View File

@@ -413,8 +413,9 @@ class ChatListController extends State<ChatList>
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<ChatList>
bool waitForFirstSync = false;
/// Leaves the room locally and removes it from the local database.
Future<void> 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<void> 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

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/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<SpaceView> {
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<SpaceView> {
case SpaceChildAction.leave:
await showFutureLoadingDialog(
context: context,
future: () => room!.leave(),
future: () => safeLeave(room!),
);
}
}

View File

@@ -85,19 +85,53 @@ class _MuralChatViewState extends State<MuralChatView> {
.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<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() {
if (!mounted) return;
setState(() {});
@@ -105,7 +139,12 @@ class _MuralChatViewState extends State<MuralChatView> {
Future<void> _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<MuralChatView> {
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<MuralChatView> {
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(2),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
@@ -301,8 +345,17 @@ class _MuralChatViewState extends State<MuralChatView> {
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<void> _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 = <String, _CommentNode>{};
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,