1625 lines
55 KiB
Dart
1625 lines
55 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/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<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;
|
|
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(() {});
|
|
}
|
|
|
|
Future<void> _getTimeline() async {
|
|
timeline?.cancelSubscriptions();
|
|
_loadingHistory = false;
|
|
_historyEnded = false;
|
|
timeline = await room.getTimeline(
|
|
onUpdate: updateView,
|
|
limit: Room.defaultHistoryCount * 3,
|
|
);
|
|
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) => 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: 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<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 ...[
|
|
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<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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
...[
|
|
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,
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|