// SPDX-FileCopyrightText: 2019-Present Christian Kußowski // SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat // // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/pages/chat/send_file_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/stream_extension.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/mxc_image.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.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 && _muralMediaTypes.contains(e.messageType), ) .toList(); bool _descriptionExpanded = false; @override void initState() { super.initState(); _getTimeline(); } @override void dispose() { timeline?.cancelSubscriptions(); super.dispose(); } void updateView() { if (!mounted) return; setState(() {}); } Future _getTimeline() async { timeline?.cancelSubscriptions(); timeline = await room.getTimeline(onUpdate: updateView); if (!mounted) return; 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) => FractionallySizedBox( heightFactor: 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: 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( padding: const EdgeInsets.all(2), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 2, crossAxisSpacing: 2, ), itemCount: events.length, itemBuilder: (context, i) { 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') ?? '', ); final topic = room.topic.trim(); 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: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( 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), ), ], ), ), ), ), DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.35), shape: BoxShape.circle, ), child: IconButton( tooltip: l10n.settings, icon: const Icon(Icons.more_vert), color: Colors.white, onPressed: onEditInfo, ), ), ], ), ), ], ), Padding( padding: const EdgeInsets.fromLTRB(16, 48, 16, 4), child: _ExpandableDescription( text: topic, expanded: descriptionExpanded, onToggle: onToggleDescription, ), ), ], ); } } class _ExpandableDescription extends StatelessWidget { final String? text; final bool expanded; final VoidCallback onToggle; const _ExpandableDescription({ 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); return LayoutBuilder( builder: (context, constraints) { final painter = TextPainter( text: TextSpan(text: text, style: style), maxLines: 4, textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth); final exceeds = painter.didExceedMaxLines; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( text, style: style, maxLines: expanded ? null : 4, overflow: expanded ? null : TextOverflow.ellipsis, ), 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 _PostViewerState extends State<_PostViewer> { final TextEditingController _commentController = TextEditingController(); bool _sending = false; 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 _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 _sendComment() async { final text = _commentController.text.trim(); if (text.isEmpty || _sending) return; _commentController.clear(); setState(() => _sending = true); try { await widget.room.sendTextEvent(text, threadRootEventId: event.eventId); } finally { if (mounted) setState(() => _sending = false); } } List _comments() { final comments = widget.timeline.events .where( (e) => !e.redacted && e.relationshipType == RelationshipTypes.thread && e.relationshipEventId == event.eventId, ) .toList(); comments.sort((a, b) => a.originServerTs.compareTo(b.originServerTs)); return comments; } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = L10n.of(context); final isVideo = event.messageType == MessageTypes.Video; return StreamBuilder( stream: widget.room.client.onSync.stream .where((syncUpdate) => syncUpdate.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)), builder: (context, snapshots) { final comments = _comments(); 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( flex: 5, child: Container( color: theme.colorScheme.surfaceContainerHighest, width: double.infinity, child: isVideo ? EventVideoPlayer(event) : MxcImage( key: ValueKey(event.eventId), event: event, fit: BoxFit.contain, isThumbnail: false, ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( tooltip: l10n.editCaption, icon: const Icon(Icons.edit_outlined), onPressed: _editCaption, ), const Spacer(), if (_likeCount > 0) Padding( padding: const EdgeInsets.only(bottom: 10), child: Text( '$_likeCount', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), IconButton( tooltip: liked ? l10n.unlike : l10n.like, icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.redAccent : null, ), onPressed: _toggleLike, ), ], ), ), 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), Expanded( flex: 3, child: comments.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(16), child: Text( l10n.noCommentsYet, textAlign: TextAlign.center, style: TextStyle( color: theme.colorScheme.onSurfaceVariant, ), ), ), ) : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: comments.length, itemBuilder: (context, i) { final comment = comments[i]; return Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 6, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Avatar( mxContent: comment .senderFromMemoryOrFallback .avatarUrl, name: comment.senderFromMemoryOrFallback .calcDisplayname(), size: 32, client: widget.room.client, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( comment.senderFromMemoryOrFallback .calcDisplayname(), style: theme.textTheme.labelMedium ?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 2), Text( comment .getDisplayEvent(widget.timeline) .calcLocalizedBodyFallback( MatrixLocals(l10n), withSenderNamePrefix: false, hideReply: true, ), style: theme.textTheme.bodyMedium, ), ], ), ), ], ), ); }, ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 8, 8), child: Row( children: [ Expanded( child: TextField( controller: _commentController, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendComment(), decoration: InputDecoration( hintText: l10n.optionalMessage, isDense: true, border: InputBorder.none, ), ), ), IconButton( tooltip: l10n.send, icon: const Icon(Icons.send_outlined), onPressed: _sendComment, ), ], ), ), ], ), ); }, ); } }