// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:linkify/linkify.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart'; import 'package:tatchat/config/themes.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat/send_file_dialog.dart'; import 'package:tatchat/pages/chat/sticker_picker_dialog.dart'; import 'package:tatchat/pages/image_viewer/video_player.dart'; import 'package:tatchat/utils/adaptive_bottom_sheet.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/show_scaffold_dialog.dart'; import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/url_launcher.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/user_dialog.dart'; import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/mxc_image.dart'; import 'package:tatchat/widgets/share_scaffold_dialog.dart'; const _muralMediaTypes = { MessageTypes.Image, MessageTypes.Video, MessageTypes.Sticker, }; const _likeKey = '👍'; class MuralChatPage extends StatelessWidget { final String roomId; const MuralChatPage({super.key, required this.roomId}); @override Widget build(BuildContext context) { final room = Matrix.of(context).client.getRoomById(roomId); if (room == null) { return Scaffold( appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)), body: Center( child: Padding( padding: const EdgeInsets.all(16), child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat), ), ), ); } return MuralChatView(key: Key('mural_chat_page_$roomId'), room: room); } } class MuralChatView extends StatefulWidget { final Room room; const MuralChatView({super.key, required this.room}); @override State createState() => _MuralChatViewState(); } class _MuralChatViewState extends State { Room get room => Matrix.of(context).client.getRoomById(widget.room.id) ?? widget.room; Timeline? timeline; List get _mediaEvents => timeline == null ? const [] : timeline!.events .where( (e) => !e.redacted && e.relationshipType == null && _muralMediaTypes.contains(e.messageType), ) .toList(); bool _descriptionExpanded = false; bool _loadingHistory = false; bool _historyEnded = false; bool _initialLoading = 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.canRequestHistory || timeline.events.length <= eventCountBefore) { _historyEnded = true; } _loadingHistory = false; if (mounted) setState(() {}); } } /// Fetches older events from the server until enough media posts were /// loaded or the entire history was consumed. This makes the mural behave /// like a chat: after the initial sync only a handful of recent events are /// in the timeline which usually contain very few media posts (most events /// are thread comments), so we must actively fetch batches from the server /// until the feed is properly populated. Future _fillInitialMedia() async { var attempts = 0; const maxAttempts = 20; const targetMediaCount = 30; while (mounted && !_historyEnded && _mediaEvents.length < targetMediaCount && attempts < maxAttempts) { attempts++; await _loadMore(); } } void updateView() { if (!mounted) return; setState(() {}); } Future _getTimeline() async { timeline?.cancelSubscriptions(); _loadingHistory = false; _historyEnded = false; _initialLoading = true; timeline = await room.getTimeline( onUpdate: updateView, limit: Room.defaultHistoryCount * 3, ); if (!mounted) return; setState(() {}); await _fillInitialMedia(); _initialLoading = false; if (mounted) setState(() {}); } Future _sendFiles(List? files) async { if (files == null || files.isEmpty || !mounted) return; await showAdaptiveDialog( context: context, builder: (c) => SendFileDialog( files: files, room: room, outerContext: context, threadRootEventId: null, threadLastEventId: null, ), ); } Future _postPhoto() async { final file = await ImagePicker().pickImage(source: ImageSource.gallery); if (!mounted) return; await _sendFiles(file == null ? null : [file]); } Future _takePhoto() async { final file = await ImagePicker().pickImage(source: ImageSource.camera); if (!mounted) return; await _sendFiles(file == null ? null : [file]); } Future _postVideo() async { final file = await ImagePicker().pickVideo(source: ImageSource.gallery); if (!mounted) return; await _sendFiles(file == null ? null : [file]); } Future _recordVideo() async { final file = await ImagePicker().pickVideo( source: ImageSource.camera, maxDuration: const Duration(minutes: 1), ); if (!mounted) return; await _sendFiles(file == null ? null : [file]); } Future _showPostSheet() async { final l10n = L10n.of(context); await showAdaptiveBottomSheet( context: context, builder: (context) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 8, ), child: Text( l10n.newPost, style: Theme.of(context).textTheme.titleLarge, ), ), ListTile( leading: const Icon(Icons.photo_library_outlined), title: Text(l10n.postPhoto), onTap: () { Navigator.of(context, rootNavigator: true).pop(); _postPhoto(); }, ), ListTile( leading: const Icon(Icons.photo_camera_outlined), title: Text(l10n.takeAPhoto), onTap: () { Navigator.of(context, rootNavigator: true).pop(); _takePhoto(); }, ), ListTile( leading: const Icon(Icons.video_library_outlined), title: Text(l10n.postVideo), onTap: () { Navigator.of(context, rootNavigator: true).pop(); _postVideo(); }, ), ListTile( leading: const Icon(Icons.videocam_outlined), title: Text(l10n.recordAVideo), onTap: () { Navigator.of(context, rootNavigator: true).pop(); _recordVideo(); }, ), ], ), ), ), ); } void _openMedia(Event event) { final timeline = this.timeline; if (timeline == null) return; final theme = Theme.of(context); showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, backgroundColor: theme.colorScheme.surface, builder: (context) => AnimatedPadding( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, padding: EdgeInsets.only( bottom: MediaQuery.viewInsetsOf(context).bottom, ), child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.92, ), child: _PostViewer( key: Key('post_viewer_${event.eventId}'), event: event, room: room, timeline: timeline, ), ), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = L10n.of(context); final coverColor = theme.colorScheme.secondaryContainer; final gridCellSize = (MediaQuery.sizeOf(context).width - 4) / 3; final events = _mediaEvents; final isAdmin = room.ownPowerLevel >= PowerLevel.admin; return Scaffold( extendBodyBehindAppBar: true, floatingActionButton: isAdmin ? FloatingActionButton( onPressed: _showPostSheet, tooltip: l10n.newPost, child: const Icon(Icons.add_photo_alternate_outlined), ) : null, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, foregroundColor: Colors.white, iconTheme: const IconThemeData(color: Colors.white), ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Header( room: room, coverColor: coverColor, descriptionExpanded: _descriptionExpanded, onToggleDescription: () => setState(() => _descriptionExpanded = !_descriptionExpanded), onEditInfo: () => context.go('/rooms/${room.id}/details'), ), const Divider(height: 1), Expanded( child: _initialLoading ? const Center(child: CircularProgressIndicator.adaptive()) : events.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.photo_library_outlined, size: 72, color: theme.colorScheme.secondary, ), const SizedBox(height: 12), Text( l10n.noPostsYet, textAlign: TextAlign.center, style: TextStyle( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ) : GridView.builder( controller: _scrollController, padding: const EdgeInsets.all(2), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 2, crossAxisSpacing: 2, ), 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, client: room.client, cellSize: gridCellSize, onTap: () => _openMedia(event), ); }, ), ), ], ), ); } } class _Header extends StatelessWidget { final Room room; final Color coverColor; final bool descriptionExpanded; final VoidCallback onToggleDescription; final VoidCallback onEditInfo; const _Header({ required this.room, required this.coverColor, required this.descriptionExpanded, required this.onToggleDescription, required this.onEditInfo, }); @override Widget build(BuildContext context) { const coverHeight = 200.0; const avatarSize = 72.0; final theme = Theme.of(context); final l10n = L10n.of(context); final displayName = room.getLocalizedDisplayname(MatrixLocals(l10n)); final creatorId = room.creatorUserIds.isEmpty ? null : room.creatorUserIds.first; final creatorState = creatorId == null ? null : room.getState(EventTypes.RoomMember, creatorId); final creatorName = creatorState?.content.tryGet('displayname') ?? creatorId; final creatorAvatar = Uri.tryParse( creatorState?.content.tryGet('avatar_url') ?? '', ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Stack( clipBehavior: Clip.none, children: [ SizedBox( height: coverHeight, child: Stack( fit: StackFit.expand, children: [ DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ coverColor, theme.colorScheme.primaryContainer, ], ), ), ), if (room.avatar != null) MxcImage( uri: room.avatar, client: room.client, width: MediaQuery.sizeOf(context).width, height: coverHeight, fit: BoxFit.cover, ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.45), ], stops: const [0.55, 1.0], ), ), ), ], ), ), Positioned( left: 16, bottom: -avatarSize / 2, child: Avatar( mxContent: creatorAvatar, name: creatorName, size: avatarSize, client: room.client, shapeBorder: CircleBorder( side: BorderSide(width: 3, color: theme.colorScheme.surface), ), ), ), Positioned( left: 16 + avatarSize + 12, right: 8, bottom: -avatarSize / 2, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( displayName, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: Colors.white, shadows: const [ Shadow( color: Colors.black54, blurRadius: 6, offset: Offset(0, 1), ), ], ), ), ), ), ], ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 8, 0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, shape: BoxShape.circle, ), child: IconButton( tooltip: l10n.settings, icon: const Icon(Icons.more_vert), onPressed: onEditInfo, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), child: _MuralDescription( room: room, text: room.topic, expanded: descriptionExpanded, onToggle: onToggleDescription, ), ), ], ); } } class _MuralDescription extends StatelessWidget { final Room room; final String? text; final bool expanded; final VoidCallback onToggle; const _MuralDescription({ required this.room, required this.text, required this.expanded, required this.onToggle, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = L10n.of(context); final text = this.text?.trim(); if (text == null || text.isEmpty) return const SizedBox.shrink(); final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4); final linkStyle = TextStyle( color: theme.colorScheme.primary, decorationColor: theme.colorScheme.primary, decoration: TextDecoration.underline, ); const linkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()]; final elements = linkify(text, linkifiers: linkifiers); Future openLink(LinkableElement rawLink) async { final url = rawLink.url; if (rawLink is UserTagElement) { final userTag = rawLink.userTag; var mentionId = userTag.startsWith('@') ? userTag.substring(1) : userTag; if (!mentionId.contains(':')) { mentionId = '$mentionId:${room.client.homeserver?.host ?? ''}'; } final memberState = room.getState(EventTypes.RoomMember, mentionId); final profile = Profile( userId: mentionId, displayName: memberState?.content.tryGet('displayname') ?? userTag, avatarUrl: Uri.tryParse( memberState?.content.tryGet('avatar_url') ?? '', ), ); await UserDialog.show(context: context, profile: profile); return; } UrlLauncher(context, url).launchUrl(); } Widget richText({bool measure = false}) => Text.rich( buildTextSpan( elements, style: style, linkStyle: linkStyle, onOpen: openLink, ), maxLines: expanded ? null : 4, overflow: expanded ? null : TextOverflow.ellipsis, ); return LayoutBuilder( builder: (context, constraints) { final painter = TextPainter( text: buildTextSpan(elements, style: style, linkStyle: linkStyle), maxLines: 4, textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth); final exceeds = painter.didExceedMaxLines; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ richText(), if (exceeds) TextButton( onPressed: onToggle, style: TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: const Size(0, 32), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Text(expanded ? l10n.showLess : l10n.showMore), ), ], ); }, ); } } class _MediaCell extends StatelessWidget { final Event event; final Client client; final double cellSize; final VoidCallback onTap; const _MediaCell({ required this.event, required this.client, required this.cellSize, required this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isVideo = event.messageType == MessageTypes.Video; final thumbnailUri = isVideo ? Uri.tryParse( event.content .tryGetMap('info') ?.tryGet('thumbnail_url') ?? '', ) : null; return GestureDetector( onTap: onTap, child: ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: Stack( fit: StackFit.expand, children: [ if (isVideo) thumbnailUri == null ? ColoredBox( color: theme.colorScheme.surfaceContainerHighest, child: const Center( child: Icon( Icons.video_library_outlined, size: 36, color: Colors.white70, ), ), ) : MxcImage( uri: thumbnailUri, client: client, width: cellSize, height: cellSize, fit: BoxFit.cover, ) else MxcImage( key: ValueKey(event.eventId), event: event, width: cellSize, height: cellSize, fit: BoxFit.cover, isThumbnail: true, ), if (isVideo) const Center( child: Icon( Icons.play_circle_fill, size: 40, color: Colors.white, ), ), ], ), ), ); } } class _PostViewer extends StatefulWidget { final Event event; final Room room; final Timeline timeline; const _PostViewer({ super.key, required this.event, required this.room, required this.timeline, }); @override State<_PostViewer> createState() => _PostViewerState(); } class _CommentNode { final Event event; final List<_CommentNode> replies; _CommentNode(this.event) : replies = []; } class _PostViewerState extends State<_PostViewer> { final TextEditingController _commentController = TextEditingController(); bool _sending = false; Event? _replyingTo; Event get event => widget.event; @override void dispose() { _commentController.dispose(); super.dispose(); } Event? _myReaction() { final reactions = event.aggregatedEvents( widget.timeline, RelationshipTypes.reaction, ); for (final reaction in reactions) { final senderId = reaction.senderId; if (senderId != widget.room.client.userID) continue; final key = reaction.content .tryGetMap('m.relates_to') ?.tryGet('key'); if (key == _likeKey) return reaction; } return null; } int get _likeCount => event .aggregatedEvents(widget.timeline, RelationshipTypes.reaction) .where( (e) => e.content .tryGetMap('m.relates_to') ?.tryGet('key') == _likeKey, ) .length; Future _toggleLike() async { final reaction = _myReaction(); if (reaction != null) { await reaction.redactEvent(); } else { await widget.room.sendReaction(event.eventId, _likeKey); } } Future _forwardPost() async { await showScaffoldDialog( context: context, builder: (context) => ShareScaffoldDialog(items: [ContentShareItem(event.content.copy())]), ); } Future _toggleCommentReaction(Event comment, String key) async { final reactions = comment.aggregatedEvents( widget.timeline, RelationshipTypes.reaction, ); for (final r in reactions) { if (r.senderId != widget.room.client.userID) continue; final rKey = r.content .tryGetMap('m.relates_to') ?.tryGet('key'); if (rKey == key) { await r.redactEvent(); return; } } await widget.room.sendReaction(comment.eventId, key); } Future _showCommentReactionPicker(Event comment) async { final theme = Theme.of(context); await showModalBottomSheet( context: context, useSafeArea: true, backgroundColor: theme.colorScheme.surface, builder: (context) => SizedBox( height: MediaQuery.sizeOf(context).height * 0.5, child: EmojiPicker( onEmojiSelected: (_, emoji) { Navigator.of(context).pop(); _toggleCommentReaction(comment, emoji.emoji); }, config: Config( locale: Localizations.localeOf(context), emojiViewConfig: EmojiViewConfig( backgroundColor: theme.colorScheme.onInverseSurface, ), bottomActionBarConfig: const BottomActionBarConfig(enabled: false), categoryViewConfig: CategoryViewConfig( backspaceColor: theme.colorScheme.primary, iconColor: theme.colorScheme.primary.withAlpha(128), iconColorSelected: theme.colorScheme.primary, indicatorColor: theme.colorScheme.primary, backgroundColor: theme.colorScheme.surface, ), skinToneConfig: const SkinToneConfig(indicatorColor: Colors.grey), ), ), ), ); } List<_CommentNode> _buildCommentTree() { final allComments = widget.timeline.events .where( (e) => !e.redacted && e.relationshipType == RelationshipTypes.thread && e.relationshipEventId == event.eventId, ) .toList(); allComments.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); final nodeMap = {}; final roots = <_CommentNode>[]; for (final comment in allComments) { nodeMap[comment.eventId] = _CommentNode(comment); } String? resolveRoot(String? id) { var current = id; final visited = {}; while (current != null && current != event.eventId && nodeMap.containsKey(current)) { if (!visited.add(current)) break; final node = nodeMap[current]!; final parentId = node.event.inReplyToEventId(includingFallback: false); if (parentId == null || parentId == event.eventId || !nodeMap.containsKey(parentId)) { return current; } current = parentId; } return null; } for (final comment in allComments) { final replyToId = comment.inReplyToEventId(includingFallback: false); final node = nodeMap[comment.eventId]!; if (replyToId != null && replyToId != event.eventId && nodeMap.containsKey(replyToId)) { final rootId = resolveRoot(replyToId); if (rootId != null && rootId != comment.eventId) { nodeMap[rootId]!.replies.add(node); continue; } } roots.add(node); } return roots; } Future _editCaption() async { final l10n = L10n.of(context); final currentBody = event.getDisplayEvent(widget.timeline).body.trim(); final result = await showTextInputDialog( context: context, title: l10n.editCaption, okLabel: l10n.ok, cancelLabel: l10n.cancel, hintText: l10n.captionHint, initialText: currentBody, maxLines: 3, ); if (result == null || result.trim().isEmpty || result.trim() == currentBody) { return; } await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId); } Future _deletePost() async { final l10n = L10n.of(context); final delete = await showOkCancelAlertDialog( context: context, title: l10n.areYouSure, message: l10n.areYouSureDeletePost, okLabel: l10n.delete, cancelLabel: l10n.cancel, ); if (delete != OkCancelResult.ok || !mounted) return; try { await event.redactEvent(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)), ); return; } if (!mounted) return; Navigator.of(context).pop(); } Future _reportPost() async { final l10n = L10n.of(context); final reason = await showTextInputDialog( context: context, title: l10n.whyDoYouWantToReportThis, okLabel: l10n.report, cancelLabel: l10n.cancel, hintText: l10n.reason, ); if (reason == null || reason.isEmpty || !mounted) return; await widget.room.client.reportEvent( widget.room.id, event.eventId, reason: reason, ); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.contentHasBeenReported))); } Future _deleteComment(Event comment) async { final l10n = L10n.of(context); final delete = await showOkCancelAlertDialog( context: context, title: l10n.areYouSure, message: l10n.areYouSureDeletePost, okLabel: l10n.delete, cancelLabel: l10n.cancel, ); if (delete != OkCancelResult.ok || !mounted) return; try { await comment.redactEvent(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)), ); } } Future _reportComment(Event comment) async { final l10n = L10n.of(context); final reason = await showTextInputDialog( context: context, title: l10n.whyDoYouWantToReportThis, okLabel: l10n.report, cancelLabel: l10n.cancel, hintText: l10n.reason, ); if (reason == null || reason.isEmpty || !mounted) return; await widget.room.client.reportEvent( widget.room.id, comment.eventId, reason: reason, ); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.contentHasBeenReported))); } Future _sendComment() async { final text = _commentController.text.trim(); if (text.isEmpty || _sending) return; _commentController.clear(); final replyTo = _replyingTo; setState(() { _sending = true; _replyingTo = null; }); try { await widget.room.sendTextEvent( text, threadRootEventId: event.eventId, inReplyTo: replyTo, ); } finally { if (mounted) setState(() => _sending = false); } } void _typeEmoji(Emoji? emoji) { if (emoji == null) return; final emojiText = emoji.emoji; final value = _commentController.value; if (value.selection.start != -1 && value.selection.isValid) { final newText = value.text.replaceRange( value.selection.start, value.selection.end, emojiText, ); _commentController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed( offset: value.selection.start + emojiText.characters.length, ), ); } else { _commentController.text += emojiText; } } Future _showEmojiPicker() async { final theme = Theme.of(context); await showModalBottomSheet( context: context, useSafeArea: true, backgroundColor: theme.colorScheme.surface, builder: (context) => SizedBox( height: MediaQuery.sizeOf(context).height * 0.5, child: EmojiPicker( onEmojiSelected: (_, emoji) { _typeEmoji(emoji); }, config: Config( locale: Localizations.localeOf(context), emojiViewConfig: EmojiViewConfig( backgroundColor: theme.colorScheme.onInverseSurface, ), bottomActionBarConfig: const BottomActionBarConfig(enabled: false), categoryViewConfig: CategoryViewConfig( backspaceColor: theme.colorScheme.primary, iconColor: theme.colorScheme.primary.withAlpha(128), iconColorSelected: theme.colorScheme.primary, indicatorColor: theme.colorScheme.primary, backgroundColor: theme.colorScheme.surface, ), skinToneConfig: const SkinToneConfig(indicatorColor: Colors.grey), ), ), ), ); } Future _sendSticker() async { final theme = Theme.of(context); await showModalBottomSheet( context: context, useSafeArea: true, backgroundColor: theme.colorScheme.surface, builder: (context) => SizedBox( height: MediaQuery.sizeOf(context).height * 0.6, child: StickerPickerDialog( room: widget.room, onSelected: (sticker) { Navigator.of(context, rootNavigator: true).pop(); widget.room.sendEvent( { 'body': sticker.body, 'info': sticker.info ?? {}, 'url': sticker.url.toString(), }, type: EventTypes.Sticker, threadRootEventId: event.eventId, ); }, ), ), ); } void _openProfile(Event comment) { final sender = comment.senderFromMemoryOrFallback; UserDialog.show( context: context, profile: Profile( userId: sender.id, displayName: sender.calcDisplayname(), avatarUrl: sender.avatarUrl, ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = L10n.of(context); final isVideo = event.messageType == MessageTypes.Video; final imageInfo = event.content.tryGetMap('info'); final imgW = imageInfo?.tryGet('w')?.toDouble(); final imgH = imageInfo?.tryGet('h')?.toDouble(); final hasImageDimensions = imgW != null && imgH != null && imgW > 0 && imgH > 0; return StreamBuilder( stream: widget.room.client.onSync.stream .where((syncUpdate) => syncUpdate.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)), builder: (context, snapshots) { final commentTree = _buildCommentTree(); final caption = event.getDisplayEvent(widget.timeline).body.trim(); final liked = _myReaction() != null; return SafeArea( top: false, left: false, right: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 36, height: 5, margin: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(3), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ Expanded( child: Text( widget.room.getLocalizedDisplayname(MatrixLocals(l10n)), maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ), IconButton( icon: const Icon(Icons.close), tooltip: l10n.close, onPressed: () => Navigator.of(context).pop(), ), ], ), ), Expanded( child: ListView( padding: EdgeInsets.zero, children: [ Container( color: theme.colorScheme.surfaceContainerHighest, width: double.infinity, 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, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( tooltip: liked ? l10n.unlike : l10n.like, icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.redAccent : null, ), onPressed: _toggleLike, ), if (_likeCount > 0) Padding( padding: const EdgeInsets.only(bottom: 10), child: Text( '$_likeCount', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), IconButton( tooltip: l10n.forward, icon: Icon(Icons.adaptive.share_outlined), onPressed: _forwardPost, ), const Spacer(), PopupMenuButton( tooltip: l10n.more, onSelected: (action) async { switch (action) { case 'edit': await _editCaption(); case 'delete': await _deletePost(); case 'report': await _reportPost(); } }, itemBuilder: (context) => [ PopupMenuItem( value: 'edit', child: ListTile( leading: const Icon(Icons.edit_outlined), title: Text(l10n.editCaption), contentPadding: EdgeInsets.zero, ), ), PopupMenuItem( value: 'delete', child: ListTile( leading: const Icon(Icons.delete_outline), title: Text(l10n.delete), contentPadding: EdgeInsets.zero, ), ), PopupMenuItem( value: 'report', child: ListTile( leading: const Icon(Icons.flag_outlined), title: Text(l10n.report), contentPadding: EdgeInsets.zero, ), ), ], icon: const Icon(Icons.more_vert), ), ], ), ), if (caption.trim().isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( caption.trim(), style: theme.textTheme.bodyMedium, ), ), const Divider(height: 1), if (commentTree.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Text( l10n.noCommentsYet, textAlign: TextAlign.center, style: TextStyle( color: theme.colorScheme.onSurfaceVariant, ), ), ) else ...[ for (var i = 0; i < commentTree.length; i++) ...[ if (i > 0) const Divider(height: 1), _CommentTile( node: commentTree[i], 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, ), ], ], ], ), ), if (_replyingTo != null) Container( color: theme.colorScheme.secondaryContainer.withValues( alpha: 0.5, ), padding: const EdgeInsets.fromLTRB(16, 6, 8, 6), child: Row( children: [ Icon( Icons.reply, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: 4), Expanded( child: Text( '${l10n.reply} ${_replyingTo!.senderFromMemoryOrFallback.calcDisplayname()}', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, ), ), ), IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.close, size: 18), onPressed: () => setState(() => _replyingTo = null), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), child: Row( children: [ IconButton( tooltip: l10n.emojis, icon: const Icon(Icons.emoji_emotions_outlined), onPressed: _showEmojiPicker, ), IconButton( tooltip: l10n.stickers, icon: const Icon(Icons.emoji_emotions), onPressed: _sendSticker, ), Expanded( child: TextField( controller: _commentController, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendComment(), decoration: InputDecoration( hintText: _replyingTo != null ? '${l10n.reply} ${_replyingTo!.senderFromMemoryOrFallback.calcDisplayname()}' : l10n.optionalMessage, isDense: true, border: InputBorder.none, ), ), ), IconButton( tooltip: l10n.send, icon: const Icon(Icons.send_outlined), onPressed: _sendComment, ), ], ), ), ], ), ); }, ); } } class _CommentTile extends StatelessWidget { final _CommentNode node; final int depth; final Timeline timeline; final String? currentUserId; final ValueChanged onReply; final void Function(Event comment, String key) onToggleReaction; final ValueChanged onShowReactionPicker; final ValueChanged onOpenProfile; final ValueChanged onDelete; final ValueChanged onReport; const _CommentTile({ required this.node, required this.depth, required this.timeline, required this.currentUserId, required this.onReply, required this.onToggleReaction, required this.onShowReactionPicker, required this.onOpenProfile, required this.onDelete, required this.onReport, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = L10n.of(context); final comment = node.event; final isSticker = comment.messageType == MessageTypes.Sticker; final sender = comment.senderFromMemoryOrFallback; final indent = depth * 32.0; final reactions = comment.aggregatedEvents( timeline, RelationshipTypes.reaction, ); final reactionCounts = {}; for (final r in reactions) { final key = r.content .tryGetMap('m.relates_to') ?.tryGet('key'); if (key != null) { reactionCounts[key] = (reactionCounts[key] ?? 0) + 1; } } final myUserId = comment.room.client.userID; return Padding( padding: EdgeInsets.only( left: 16.0 + indent, right: 16, top: depth == 0 ? 8 : 2, bottom: 2, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => onOpenProfile(comment), child: Avatar( mxContent: sender.avatarUrl, name: sender.calcDisplayname(), size: depth == 0 ? 32 : 24, client: comment.room.client, ), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () => onOpenProfile(comment), child: Text( sender.calcDisplayname(), style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ), const SizedBox(height: 2), if (isSticker) MxcImage( key: ValueKey(comment.eventId), event: comment, fit: BoxFit.contain, isThumbnail: false, width: 200, ) else Text( comment .getDisplayEvent(timeline) .calcLocalizedBodyFallback( MatrixLocals(l10n), withSenderNamePrefix: false, hideReply: true, ), style: theme.textTheme.bodyMedium, ), const SizedBox(height: 4), Row( children: [ if (depth == 0) GestureDetector( onTap: () => onReply(comment), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Text( l10n.reply, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w500, ), ), ), ), if (depth == 0) const SizedBox(width: 12), ...reactionCounts.entries.map((entry) { final isMyReaction = reactions.any( (r) => r.senderId == myUserId && r.content .tryGetMap( 'm.relates_to', ) ?.tryGet('key') == entry.key, ); return Padding( padding: const EdgeInsets.only(right: 4), child: GestureDetector( onTap: () => onToggleReaction(comment, entry.key), child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: isMyReaction ? theme.colorScheme.primaryContainer : theme .colorScheme .surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: isMyReaction ? Border.all( color: theme.colorScheme.primary, width: 1, ) : null, ), child: Text( '${entry.key} ${entry.value}', style: theme.textTheme.labelSmall, ), ), ), ); }), GestureDetector( onTap: () => onShowReactionPicker(comment), child: Padding( padding: const EdgeInsets.all(2), child: Icon( Icons.add_reaction_outlined, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ), ), const Spacer(), PopupMenuButton( tooltip: l10n.more, padding: EdgeInsets.zero, color: theme.colorScheme.surface, onSelected: (action) async { switch (action) { case 'delete': onDelete(comment); case 'report': onReport(comment); } }, itemBuilder: (context) => [ if (comment.senderId == currentUserId) PopupMenuItem( value: 'delete', child: ListTile( leading: const Icon(Icons.delete_outline), title: Text(l10n.deleteMessage), contentPadding: EdgeInsets.zero, ), ), PopupMenuItem( value: 'report', child: ListTile( leading: const Icon(Icons.flag_outlined), title: Text(l10n.reportMessage), contentPadding: EdgeInsets.zero, ), ), ], icon: Icon( Icons.more_vert, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ], ), ), ], ), ...[ for (var i = 0; i < node.replies.length; i++) ...[ if (i > 0) const Divider(height: 1), _CommentTile( node: node.replies[i], depth: depth + 1, timeline: timeline, currentUserId: currentUserId, onReply: onReply, onToggleReaction: onToggleReaction, onShowReactionPicker: onShowReactionPicker, onOpenProfile: onOpenProfile, onDelete: onDelete, onReport: onReport, ), ], ], ], ), ); } }