feat: cartões de publicação em canais e corrreções de salas arquivadas
- Canais: publicações viram cartões de largura total mantendo todas as funções do balão (reações, resposta, editar, excluir, comentários em thread). Composer de publicação fica restrito a admins no feed; membros comentam via tela de thread. Canais novos usam events_default 0 para permitir comentários e reações. - Ao sair/excluir uma sala, a cópia local é removida do banco imediatamente; salas já deixadas no servidor não apresentam mais erro ao excluir de novo. - Classificação mural/canal carregada direto do sync (room_type em importantStateEvents + postLoad) sem precisar abrir a sala. - Diálogos com raio e padding reduzidos (dialogTheme, text input e ok/cancel). - Ícones do app regenerados (logo_background) e dependência linkify.
This commit is contained in:
@@ -3,20 +3,27 @@
|
||||
//
|
||||
// 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/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:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
const _muralMediaTypes = {
|
||||
MessageTypes.Image,
|
||||
@@ -68,7 +75,10 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
? const []
|
||||
: timeline!.events
|
||||
.where(
|
||||
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType),
|
||||
(e) =>
|
||||
!e.redacted &&
|
||||
e.relationshipType == null &&
|
||||
_muralMediaTypes.contains(e.messageType),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@@ -340,7 +350,6 @@ class _Header extends StatelessWidget {
|
||||
final creatorAvatar = Uri.tryParse(
|
||||
creatorState?.content.tryGet<String>('avatar_url') ?? '',
|
||||
);
|
||||
final topic = room.topic.trim();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -406,51 +415,52 @@ class _Header extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
@@ -460,12 +470,14 @@ class _Header extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandableDescription extends StatelessWidget {
|
||||
class _MuralDescription extends StatelessWidget {
|
||||
final Room room;
|
||||
final String? text;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _ExpandableDescription({
|
||||
const _MuralDescription({
|
||||
required this.room,
|
||||
required this.text,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
@@ -478,10 +490,54 @@ class _ExpandableDescription extends StatelessWidget {
|
||||
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: TextSpan(text: text, style: style),
|
||||
text: buildTextSpan(elements, style: style, linkStyle: linkStyle),
|
||||
maxLines: 4,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
@@ -489,12 +545,7 @@ class _ExpandableDescription extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: style,
|
||||
maxLines: expanded ? null : 4,
|
||||
overflow: expanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
richText(),
|
||||
if (exceeds)
|
||||
TextButton(
|
||||
onPressed: onToggle,
|
||||
@@ -671,6 +722,50 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
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> _sendComment() async {
|
||||
final text = _commentController.text.trim();
|
||||
if (text.isEmpty || _sending) return;
|
||||
@@ -683,6 +778,86 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Event> _comments() {
|
||||
final comments = widget.timeline.events
|
||||
.where(
|
||||
@@ -696,6 +871,18 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
return comments;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -772,11 +959,13 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.editCaption,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: _editCaption,
|
||||
tooltip: liked ? l10n.unlike : l10n.like,
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite : Icons.favorite_border,
|
||||
color: liked ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: _toggleLike,
|
||||
),
|
||||
const Spacer(),
|
||||
if (_likeCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
@@ -787,13 +976,46 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: liked ? l10n.unlike : l10n.like,
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite : Icons.favorite_border,
|
||||
color: liked ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: _toggleLike,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -827,6 +1049,9 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, i) {
|
||||
final comment = comments[i];
|
||||
final isSticker =
|
||||
comment.messageType == MessageTypes.Sticker;
|
||||
final sender = comment.senderFromMemoryOrFallback;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -835,14 +1060,14 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: comment
|
||||
.senderFromMemoryOrFallback
|
||||
.avatarUrl,
|
||||
name: comment.senderFromMemoryOrFallback
|
||||
.calcDisplayname(),
|
||||
size: 32,
|
||||
client: widget.room.client,
|
||||
GestureDetector(
|
||||
onTap: () => _openProfile(comment),
|
||||
child: Avatar(
|
||||
mxContent: sender.avatarUrl,
|
||||
name: sender.calcDisplayname(),
|
||||
size: 32,
|
||||
client: widget.room.client,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -850,25 +1075,36 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
comment.senderFromMemoryOrFallback
|
||||
.calcDisplayname(),
|
||||
style: theme.textTheme.labelMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _openProfile(comment),
|
||||
child: Text(
|
||||
sender.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,
|
||||
),
|
||||
if (isSticker)
|
||||
MxcImage(
|
||||
key: ValueKey(comment.eventId),
|
||||
event: comment,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
width: double.infinity,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
comment
|
||||
.getDisplayEvent(widget.timeline)
|
||||
.calcLocalizedBodyFallback(
|
||||
MatrixLocals(l10n),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -879,9 +1115,19 @@ class _PostViewerState extends State<_PostViewer> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user