Files
tatuchat/lib/pages/mural/mural_chat_page.dart
Welton Moura b17c22321e
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
largura imagem mural, comentarios
2026-09-14 13:47:16 -03:00

1566 lines
53 KiB
Dart

// 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/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<MuralChatView> createState() => _MuralChatViewState();
}
class _MuralChatViewState extends State<MuralChatView> {
Room get room =>
Matrix.of(context).client.getRoomById(widget.room.id) ?? widget.room;
Timeline? timeline;
List<Event> get _mediaEvents => timeline == null
? const []
: timeline!.events
.where(
(e) =>
!e.redacted &&
e.relationshipType == null &&
_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<void> _getTimeline() async {
timeline?.cancelSubscriptions();
timeline = await room.getTimeline(onUpdate: updateView);
if (!mounted) return;
setState(() {});
}
Future<void> _sendFiles(List<XFile>? 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<void> _postPhoto() async {
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
if (!mounted) return;
await _sendFiles(file == null ? null : [file]);
}
Future<void> _takePhoto() async {
final file = await ImagePicker().pickImage(source: ImageSource.camera);
if (!mounted) return;
await _sendFiles(file == null ? null : [file]);
}
Future<void> _postVideo() async {
final file = await ImagePicker().pickVideo(source: ImageSource.gallery);
if (!mounted) return;
await _sendFiles(file == null ? null : [file]);
}
Future<void> _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<void> _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<void>(
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<String>('displayname') ?? creatorId;
final creatorAvatar = Uri.tryParse(
creatorState?.content.tryGet<String>('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<void> 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<String>('displayname') ?? userTag,
avatarUrl: Uri.tryParse(
memberState?.content.tryGet<String>('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<String, Object?>('info')
?.tryGet<String>('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<String, Object?>('m.relates_to')
?.tryGet<String>('key');
if (key == _likeKey) return reaction;
}
return null;
}
int get _likeCount => event
.aggregatedEvents(widget.timeline, RelationshipTypes.reaction)
.where(
(e) =>
e.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key') ==
_likeKey,
)
.length;
Future<void> _toggleLike() async {
final reaction = _myReaction();
if (reaction != null) {
await reaction.redactEvent();
} else {
await widget.room.sendReaction(event.eventId, _likeKey);
}
}
Future<void> _forwardPost() async {
await showScaffoldDialog(
context: context,
builder: (context) => ShareScaffoldDialog(
items: [ContentShareItem(event.content.copy())],
),
);
}
Future<void> _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<String, Object?>('m.relates_to')
?.tryGet<String>('key');
if (rKey == key) {
await r.redactEvent();
return;
}
}
await widget.room.sendReaction(comment.eventId, key);
}
Future<void> _showCommentReactionPicker(Event comment) async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
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 = <String, _CommentNode>{};
final roots = <_CommentNode>[];
for (final comment in allComments) {
nodeMap[comment.eventId] = _CommentNode(comment);
}
String? resolveRoot(String? id) {
var current = id;
final visited = <String>{};
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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _showEmojiPicker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
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<void> _sendSticker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
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<String, Object?>('info');
final imgW = imageInfo?.tryGet<num>('w')?.toDouble();
final imgH = imageInfo?.tryGet<num>('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<String>(
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
...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,
)),
],
),
),
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<Event> onReply;
final void Function(Event comment, String key) onToggleReaction;
final ValueChanged<Event> onShowReactionPicker;
final ValueChanged<Event> onOpenProfile;
final ValueChanged<Event> onDelete;
final ValueChanged<Event> 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 = <String, int>{};
for (final r in reactions) {
final key = r.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('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<String, Object?>(
'm.relates_to',
)
?.tryGet<String>('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<String>(
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,
),
),
],
),
],
),
),
],
),
...node.replies.map(
(reply) => _CommentTile(
node: reply,
depth: depth + 1,
timeline: timeline,
currentUserId: currentUserId,
onReply: onReply,
onToggleReaction: onToggleReaction,
onShowReactionPicker: onShowReactionPicker,
onOpenProfile: onOpenProfile,
onDelete: onDelete,
onReport: onReport,
),
),
],
),
);
}
}