feat: cartões de publicação em canais e corrreções de salas arquivadas
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled

- 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:
2026-09-14 08:30:25 -03:00
parent 41a6a52393
commit ebafa1dc39
29 changed files with 10543 additions and 9965 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -56,6 +56,17 @@ abstract class FluffyThemes {
brightness: brightness, brightness: brightness,
colorScheme: colorScheme, colorScheme: colorScheme,
dividerColor: dividerColor, dividerColor: dividerColor,
dialogTheme: DialogThemeData(
alignment: Alignment.center,
// Compact dialogs: less padding and a smaller corner radius than the
// Material 3 default (28px), so modals like "set chat description"
// leave more room for the text field.
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
),
segmentedButtonTheme: SegmentedButtonThemeData( segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom( style: SegmentedButton.styleFrom(
iconColor: colorScheme.onSurface, iconColor: colorScheme.onSurface,

View File

@@ -2814,19 +2814,25 @@
"unexpectedErrorOccurded": "Unfortunately, an unexpected error has occurred: {msg}", "unexpectedErrorOccurded": "Unfortunately, an unexpected error has occurred: {msg}",
"@unexpectedErrorOccurded": { "@unexpectedErrorOccurded": {
"placeholders": { "placeholders": {
"msg": {"type": "String"} "msg": {
"type": "String"
}
} }
}, },
"incomingCallFrom": "Incoming call from {user}", "incomingCallFrom": "Incoming call from {user}",
"@incomingCallFrom": { "@incomingCallFrom": {
"placeholders": { "placeholders": {
"user": {"type": "String"} "user": {
"type": "String"
}
} }
}, },
"serverRespondedWithError": "The server has replied with an error message: {error}", "serverRespondedWithError": "The server has replied with an error message: {error}",
"@serverRespondedWithError": { "@serverRespondedWithError": {
"placeholders": { "placeholders": {
"error": {"type": "String"} "error": {
"type": "String"
}
} }
}, },
"channels": "Channels", "channels": "Channels",
@@ -2913,5 +2919,9 @@
"@captionHint": { "@captionHint": {
"type": "String", "type": "String",
"placeholders": {} "placeholders": {}
},
"areYouSureDeletePost": "Delete",
"@areYouSureDeletePost": {
"description": "Delete this post from the mural"
} }
} }

View File

@@ -2042,5 +2042,6 @@
"@captionHint": { "@captionHint": {
"type": "String", "type": "String",
"placeholders": {} "placeholders": {}
} },
"areYouSureDeletePost": "Eliminar"
} }

View File

@@ -2565,5 +2565,6 @@
"@captionHint": { "@captionHint": {
"type": "String", "type": "String",
"placeholders": {} "placeholders": {}
} },
"areYouSureDeletePost": "Apagar"
} }

View File

@@ -2245,5 +2245,6 @@
"@captionHint": { "@captionHint": {
"type": "String", "type": "String",
"placeholders": {} "placeholders": {}
} },
"areYouSureDeletePost": "Eliminar"
} }

View File

@@ -31,13 +31,32 @@ class ArchiveController extends State<Archive> {
context: context, context: context,
future: () async { future: () async {
Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}'); Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}');
await archive[i].room.forget(); await forgetRoom(archive[i].room);
archive.removeAt(i); archive.removeAt(i);
}, },
); );
setState(() {}); setState(() {});
} }
/// Forgets [room] and treats a room that is already gone on the server as
/// success, so a leftover local copy can always be removed without errors.
Future<void> forgetRoom(Room room) async {
try {
await room.forget();
} on MatrixException catch (e, s) {
if (!{
MatrixError.M_FORBIDDEN,
MatrixError.M_NOT_FOUND,
MatrixError.M_UNKNOWN,
}.contains(e.error)) {
Logs().w('Failed to forget room ${room.id}', e, s);
rethrow;
}
Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s);
room.client.rooms.remove(room);
}
}
Future<void> forgetAllAction() async { Future<void> forgetAllAction() async {
final archive = this.archive; final archive = this.archive;
if (archive.isEmpty) return; if (archive.isEmpty) return;
@@ -62,7 +81,7 @@ class ArchiveController extends State<Archive> {
Logs().v( Logs().v(
'Forget room ${archive.last.room.getLocalizedDisplayname()}', 'Forget room ${archive.last.room.getLocalizedDisplayname()}',
); );
await archive.last.room.forget(); await forgetRoom(archive.last.room);
archive.removeLast(); archive.removeLast();
} }
}, },

View File

@@ -10,7 +10,6 @@ import 'package:collection/collection.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/setting_keys.dart'; import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/config/themes.dart'; import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/l10n/l10n.dart';
@@ -28,6 +27,7 @@ import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/other_party_can_receive.dart'; import 'package:tatchat/utils/other_party_can_receive.dart';
import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/show_scaffold_dialog.dart'; import 'package:tatchat/utils/show_scaffold_dialog.dart';
@@ -85,7 +85,7 @@ class ChatPage extends StatelessWidget {
); );
} }
if (room.tags.keys.contains(AppConfig.muralTag)) { if (room.isMural) {
return MuralChatPage(key: Key('mural_page_$roomId'), roomId: roomId); return MuralChatPage(key: Key('mural_page_$roomId'), roomId: roomId);
} }

View File

@@ -22,6 +22,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import '../../config/app_config.dart'; import '../../config/app_config.dart';
import '../../utils/date_time_extension.dart'; import '../../utils/date_time_extension.dart';
import '../../utils/matrix_sdk_extensions/room_extension.dart';
class ChatEventList extends StatelessWidget { class ChatEventList extends StatelessWidget {
final ChatController controller; final ChatController controller;
@@ -237,6 +238,9 @@ class ChatEventList extends StatelessWidget {
), ),
) )
: null, : null,
fullWidthCard:
controller.activeThreadId == null &&
controller.room.isCanal,
), ),
], ],
), ),

View File

@@ -25,6 +25,7 @@ import 'package:tatchat/widgets/pulsating_widget.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions/room_extension.dart';
import '../../utils/stream_extension.dart'; import '../../utils/stream_extension.dart';
import 'chat_emoji_picker.dart'; import 'chat_emoji_picker.dart';
import 'chat_input_row.dart'; import 'chat_input_row.dart';
@@ -403,7 +404,13 @@ class ChatView extends StatelessWidget {
) )
: controller.room.canSendDefaultMessages && : controller.room.canSendDefaultMessages &&
controller.room.membership == controller.room.membership ==
Membership.join Membership.join &&
(controller.room.isCanal &&
controller.activeThreadId ==
null
? controller.room.ownPowerLevel >=
PowerLevel.admin
: true)
? Material( ? Material(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
color: color:

View File

@@ -55,6 +55,12 @@ class Message extends StatelessWidget {
final bool isCollapsed; final bool isCollapsed;
final Set<String> bigEmojis; final Set<String> bigEmojis;
/// Renders the message as a full-width publication card (used in canal
/// rooms). Keeps every message feature (reply, reactions, edit, delete,
/// thread…), but drops the left/right bubble layout and stretches the
/// "bubble" over the whole available width.
final bool fullWidthCard;
const Message( const Message(
this.event, { this.event, {
this.nextEvent, this.nextEvent,
@@ -79,6 +85,7 @@ class Message extends StatelessWidget {
this.onExpand, this.onExpand,
required this.enterThread, required this.enterThread,
this.isCollapsed = false, this.isCollapsed = false,
this.fullWidthCard = false,
super.key, super.key,
}); });
@@ -105,7 +112,12 @@ class Message extends StatelessWidget {
final client = Matrix.of(context).client; final client = Matrix.of(context).client;
final ownMessage = event.senderId == client.userID; final ownMessage = event.senderId == client.userID;
final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; final card = fullWidthCard;
final alignment = card
? Alignment.topLeft
: ownMessage
? Alignment.topRight
: Alignment.topLeft;
var color = theme.colorScheme.surfaceContainerHigh; var color = theme.colorScheme.surfaceContainerHigh;
final displayTime = final displayTime =
@@ -128,6 +140,10 @@ class Message extends StatelessWidget {
previousEvent!.senderId == event.senderId && previousEvent!.senderId == event.senderId &&
previousEvent!.originServerTs.sameEnvironment(event.originServerTs); previousEvent!.originServerTs.sameEnvironment(event.originServerTs);
// In card mode the sender name moves inside the card, so show it even for
// own messages (otherwise a publication feed would be anonymous).
final showSenderName = !nextEventSameSender && (card || !ownMessage);
final textColor = ownMessage final textColor = ownMessage
? theme.onBubbleColor ? theme.onBubbleColor
: theme.colorScheme.onSurface; : theme.colorScheme.onSurface;
@@ -136,16 +152,22 @@ class Message extends StatelessWidget {
? theme.colorScheme.onTertiaryContainer ? theme.colorScheme.onTertiaryContainer
: theme.colorScheme.tertiary; : theme.colorScheme.tertiary;
final rowMainAxisAlignment = ownMessage final rowMainAxisAlignment = card
? MainAxisAlignment.start
: ownMessage
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start; : MainAxisAlignment.start;
final displayEvent = event.getDisplayEvent(timeline); final displayEvent = event.getDisplayEvent(timeline);
const hardCorner = Radius.circular(3); const hardCorner = Radius.circular(3);
const roundedCorner = Radius.circular(AppConfig.borderRadius); const roundedCorner = Radius.circular(AppConfig.borderRadius);
final borderRadius = BorderRadius.only( final borderRadius = card
? BorderRadius.all(roundedCorner)
: BorderRadius.only(
topLeft: !ownMessage ? hardCorner : roundedCorner, topLeft: !ownMessage ? hardCorner : roundedCorner,
topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner, topRight: ownMessage && nextEventSameSender
? hardCorner
: roundedCorner,
bottomLeft: !ownMessage && previousEventSameSender bottomLeft: !ownMessage && previousEventSameSender
? hardCorner ? hardCorner
: roundedCorner, : roundedCorner,
@@ -229,9 +251,9 @@ class Message extends StatelessWidget {
: SwipeDirection.startToEnd, : SwipeDirection.startToEnd,
onSwipe: (_) => onSwipe(), onSwipe: (_) => onSwipe(),
child: Container( child: Container(
constraints: const BoxConstraints( constraints: card
maxWidth: FluffyThemes.maxTimelineWidth, ? const BoxConstraints()
), : const BoxConstraints(maxWidth: FluffyThemes.maxTimelineWidth),
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 8.0, left: 8.0,
right: 8.0, right: 8.0,
@@ -242,7 +264,11 @@ class Message extends StatelessWidget {
), ),
child: Column( child: Column(
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: ownMessage ? .end : .start, crossAxisAlignment: card
? CrossAxisAlignment.stretch
: ownMessage
? .end
: .start,
children: <Widget>[ children: <Widget>[
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -275,7 +301,9 @@ class Message extends StatelessWidget {
crossAxisAlignment: .start, crossAxisAlignment: .start,
mainAxisAlignment: rowMainAxisAlignment, mainAxisAlignment: rowMainAxisAlignment,
children: [ children: [
if (longPressSelect && !event.redacted) if (card)
const SizedBox.shrink()
else if (longPressSelect && !event.redacted)
SizedBox( SizedBox(
height: avatarSize, height: avatarSize,
width: avatarSize, width: avatarSize,
@@ -318,10 +346,15 @@ class Message extends StatelessWidget {
crossAxisAlignment: .start, crossAxisAlignment: .start,
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
if (!card)
Padding( Padding(
padding: EdgeInsets.only(left: 8.0), padding: EdgeInsets.only(left: 8.0),
child: Row( child: Row(
mainAxisAlignment: ownMessage ? .end : .start, mainAxisAlignment: card
? .start
: ownMessage
? .end
: .start,
children: [ children: [
if (sender.powerLevel.role != if (sender.powerLevel.role !=
PowerLevelRole.user && PowerLevelRole.user &&
@@ -348,7 +381,8 @@ class Message extends StatelessWidget {
future: event.fetchSenderUser(), future: event.fetchSenderUser(),
builder: (context, snapshot) { builder: (context, snapshot) {
final displayname = final displayname =
snapshot.data?.calcDisplayname() ?? snapshot.data
?.calcDisplayname() ??
sender.calcDisplayname(); sender.calcDisplayname();
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
@@ -438,6 +472,7 @@ class Message extends StatelessWidget {
), ),
animateIn: animateIn, animateIn: animateIn,
child: Container( child: Container(
width: card ? double.infinity : null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: noBubble color: noBubble
? Colors.transparent ? Colors.transparent
@@ -458,15 +493,108 @@ class Message extends StatelessWidget {
AppConfig.borderRadius, AppConfig.borderRadius,
), ),
), ),
constraints: const BoxConstraints( constraints: BoxConstraints(
maxWidth: maxWidth: card
FluffyThemes.columnWidth * 1.5, ? double.infinity
: FluffyThemes.columnWidth * 1.5,
), ),
child: Column( child: Column(
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
if (card && showSenderName)
Container(
padding:
const EdgeInsets.fromLTRB(
16,
12,
16,
0,
),
child: Row(
children: [
if (sender
.powerLevel
.role !=
PowerLevelRole.user)
Padding(
padding:
const EdgeInsets.only(
right: 2.0,
),
child: Icon(
sender
.powerLevel
.role ==
PowerLevelRole
.moderator
? Icons
.add_moderator_outlined
: Icons
.admin_panel_settings,
size: 14,
color: theme
.colorScheme
.onPrimaryContainer,
),
),
Expanded(
child: FutureBuilder<User?>(
future: event
.fetchSenderUser(),
builder: (context, snapshot) {
final displayname =
snapshot.data
?.calcDisplayname() ??
sender
.calcDisplayname();
return Text(
displayname,
style: TextStyle(
fontWeight:
FontWeight
.bold,
color:
theme.brightness ==
Brightness
.light
? displayname
.colorScheme
.primary
: displayname
.colorScheme
.primaryContainer,
fontSize: 12,
),
maxLines: 1,
overflow:
TextOverflow
.ellipsis,
);
},
),
),
if (displayTime &&
event
.status
.isSent) ...[
const SizedBox(width: 8),
Text(
event.originServerTs
.localizedTimeOfDay(
context,
),
style: TextStyle(
color:
eventStateTextColor,
fontSize: 11,
),
),
],
],
),
),
if (event.inReplyToEventId( if (event.inReplyToEventId(
includingFallback: false, includingFallback: false,
) != ) !=
@@ -557,7 +685,9 @@ class Message extends StatelessWidget {
child: !hasReactions child: !hasReactions
? const SizedBox.shrink() ? const SizedBox.shrink()
: Container( : Container(
alignment: ownMessage alignment: card
? Alignment.centerLeft
: ownMessage
? Alignment.centerRight ? Alignment.centerRight
: Alignment.centerLeft, : Alignment.centerLeft,
padding: EdgeInsets.only( padding: EdgeInsets.only(
@@ -569,7 +699,11 @@ class Message extends StatelessWidget {
), ),
), ),
Row( Row(
mainAxisAlignment: ownMessage ? .end : .start, mainAxisAlignment: card
? .start
: ownMessage
? .end
: .start,
children: [ children: [
const SizedBox(width: 8), const SizedBox(width: 8),
if (event.status.isSent && if (event.status.isSent &&
@@ -643,7 +777,9 @@ class Message extends StatelessWidget {
], ],
), ),
Align( Align(
alignment: ownMessage alignment: card
? Alignment.bottomLeft
: ownMessage
? Alignment.bottomRight ? Alignment.bottomRight
: Alignment.bottomLeft, : Alignment.bottomLeft,
child: AnimatedSize( child: AnimatedSize(
@@ -861,19 +997,24 @@ class Message extends StatelessWidget {
child: threadChildren.isEmpty child: threadChildren.isEmpty
? const SizedBox.shrink() ? const SizedBox.shrink()
: Padding( : Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(top: 2.0, bottom: 8.0)
top: 2.0, .add(
bottom: 8.0, EdgeInsets.only(
left: avatarSize + 8, left: card ? 8.0 : avatarSize + 8,
),
), ),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints( constraints: BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 1.5, maxWidth: card
? double.infinity
: FluffyThemes.columnWidth * 1.5,
), ),
child: TextButton.icon( child: TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
), ),
foregroundColor: foregroundColor:
theme.colorScheme.onSecondaryContainer, theme.colorScheme.onSecondaryContainer,

View File

@@ -14,6 +14,7 @@ import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
import 'package:tatchat/utils/error_reporter.dart'; import 'package:tatchat/utils/error_reporter.dart';
import 'package:tatchat/utils/localized_exception_extension.dart'; import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/show_scaffold_dialog.dart'; import 'package:tatchat/utils/show_scaffold_dialog.dart';
import 'package:tatchat/utils/show_update_snackbar.dart'; import 'package:tatchat/utils/show_update_snackbar.dart';
@@ -402,6 +403,7 @@ class ChatListController extends State<ChatList>
} }
StreamSubscription? _onRoomTagUpdate; StreamSubscription? _onRoomTagUpdate;
StreamSubscription? _backfillSubscription;
@override @override
void initState() { void initState() {
@@ -411,6 +413,8 @@ class ChatListController extends State<ChatList>
scrollController.addListener(_onScroll); scrollController.addListener(_onScroll);
_waitForFirstSync(); _waitForFirstSync();
_backfillSubscription =
_clientStream.stream.listen((_) => backfillRoomTypeMarkers());
if (PlatformInfos.isMobile) { if (PlatformInfos.isMobile) {
_callEventSubscription = FlutterCallkitIncoming.onEvent.listen( _callEventSubscription = FlutterCallkitIncoming.onEvent.listen(
_onCallEvent, _onCallEvent,
@@ -474,6 +478,7 @@ class ChatListController extends State<ChatList>
_intentFileStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel();
_callEventSubscription?.cancel(); _callEventSubscription?.cancel();
_onRoomTagUpdate?.cancel(); _onRoomTagUpdate?.cancel();
_backfillSubscription?.cancel();
scrollController.removeListener(_onScroll); scrollController.removeListener(_onScroll);
searchController.dispose(); searchController.dispose();
searchFocusNode.dispose(); searchFocusNode.dispose();
@@ -775,7 +780,10 @@ class ChatListController extends State<ChatList>
if (confirmed == OkCancelResult.cancel) return; if (confirmed == OkCancelResult.cancel) return;
if (!mounted) return; if (!mounted) return;
await showFutureLoadingDialog(context: context, future: room.leave); await showFutureLoadingDialog(
context: context,
future: () => leaveRoom(room),
);
return; return;
case ChatContextAction.addToSpace: case ChatContextAction.addToSpace:
@@ -901,6 +909,65 @@ class ChatListController extends State<ChatList>
bool waitForFirstSync = false; bool waitForFirstSync = false;
/// Leaves the room locally and removes it from the local database.
Future<void> leaveRoom(Room room) async {
try {
await room.leave();
} on MatrixException catch (e, s) {
// If the room was already left on the server (e.g. a stale local copy
// reappeared after a restart) the leave request fails, but the user
// intent (room not being listed anymore) is already fulfilled. Clean up
// locally instead of showing an error.
if (!{
MatrixError.M_FORBIDDEN,
MatrixError.M_NOT_FOUND,
MatrixError.M_UNKNOWN,
}.contains(e.error)) {
Logs().w('Failed to leave room ${room.id}', e, s);
rethrow;
}
Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s);
}
final client = room.client;
// Make sure the room does not reappear from the local database after an
// app restart, even if the leave sync did not run in time.
await client.database.forgetRoom(room.id);
client.rooms.remove(room);
}
/// Loads the shared [roomTypeStateEvent] marker from the synced room state
/// (from the local database for partially loaded rooms) and writes it for
/// existing canal/mural rooms that only have the per-account tag of the
/// creating account. This makes the category visible right after the sync,
/// without having to open the room first. Only admins can write the marker,
/// non-admin members simply read it.
Future<void> backfillRoomTypeMarkers() async {
final client = Matrix.of(context).client;
if (client.rooms.isEmpty) return;
for (final room in client.rooms) {
if (room.membership != Membership.join) continue;
if (room.roomType != null) continue;
// Partial rooms do not keep unimportant state events (like our room type
// marker) in memory, so load them from the local database first.
await room.postLoad();
final type =
room.roomType ??
(room.tags.keys.contains(AppConfig.canalTag)
? 'canal'
: room.tags.keys.contains(AppConfig.muralTag)
? 'mural'
: null);
if (type == null || room.roomType == type) continue;
if (room.ownPowerLevel < PowerLevel.admin) continue;
try {
await room.setRoomType(type);
} catch (e, s) {
Logs().w('Failed to backfill room type $type for ${room.id}', e, s);
}
}
if (mounted) setState(() {});
}
Future<void> _waitForFirstSync() async { Future<void> _waitForFirstSync() async {
final router = GoRouter.of(context); final router = GoRouter.of(context);
final client = Matrix.of(context).client; final client = Matrix.of(context).client;
@@ -921,6 +988,7 @@ class ChatListController extends State<ChatList>
setState(() { setState(() {
waitForFirstSync = true; waitForFirstSync = true;
}); });
backfillRoomTypeMarkers();
if (client.userDeviceKeys[client.userID!]?.deviceKeys.values.any( if (client.userDeviceKeys[client.userID!]?.deviceKeys.values.any(
(device) => !device.verified && !device.blocked, (device) => !device.verified && !device.blocked,

View File

@@ -4,12 +4,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat_list/chat_list.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart';
import 'package:tatchat/pages/chat_list/chat_list_item.dart'; import 'package:tatchat/pages/chat_list/chat_list_item.dart';
import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart';
import 'package:tatchat/pages/chat_list/search_title.dart'; import 'package:tatchat/pages/chat_list/search_title.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/stream_extension.dart';
import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart';
import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/avatar.dart';
@@ -59,8 +59,9 @@ class ChatListViewBody extends StatelessWidget {
.where( .where(
(room) => (room) =>
!room.isSpace && !room.isSpace &&
!room.tags.keys.contains(AppConfig.canalTag) && !room.isLeftOrBanned &&
!room.tags.keys.contains(AppConfig.muralTag) && !room.isCanal &&
!room.isMural &&
spaceDelegateCandidates[room.id] == null, spaceDelegateCandidates[room.id] == null,
) )
.toList(); .toList();

View File

@@ -29,6 +29,8 @@ class ChatListItem extends StatelessWidget {
final void Function()? onForget; final void Function()? onForget;
final void Function() onTap; final void Function() onTap;
final String? filter; final String? filter;
final Uri? displayAvatar;
final String? displayAvatarName;
const ChatListItem( const ChatListItem(
this.room, { this.room, {
@@ -38,6 +40,8 @@ class ChatListItem extends StatelessWidget {
this.onForget, this.onForget,
this.filter, this.filter,
this.space, this.space,
this.displayAvatar,
this.displayAvatarName,
super.key, super.key,
}); });
@@ -148,11 +152,11 @@ class ChatListItem extends StatelessWidget {
AppConfig.spaceBorderRadius, AppConfig.spaceBorderRadius,
) )
: null, : null,
mxContent: room.avatar, mxContent: displayAvatar ?? room.avatar,
size: space != null size: space != null
? Avatar.defaultSize * 0.75 ? Avatar.defaultSize * 0.75
: Avatar.defaultSize, : Avatar.defaultSize,
name: displayname, name: displayAvatarName ?? displayname,
presenceUserId: directChatMatrixId, presenceUserId: directChatMatrixId,
presenceBackgroundColor: backgroundColor, presenceBackgroundColor: backgroundColor,
onTap: () => onLongPress?.call(context), onTap: () => onLongPress?.call(context),

View File

@@ -5,7 +5,6 @@
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat_list/chat_list.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart';
import 'package:tatchat/pages/chat_list/chat_list_item.dart'; import 'package:tatchat/pages/chat_list/chat_list_item.dart';
@@ -13,6 +12,7 @@ import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart';
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/stream_extension.dart';
import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/matrix.dart';
@@ -63,7 +63,8 @@ class _ChannelsTabState extends State<ChannelsTab> {
.rateLimit(const Duration(seconds: 1)), .rateLimit(const Duration(seconds: 1)),
builder: (context, _) { builder: (context, _) {
final channels = client.rooms final channels = client.rooms
.where((room) => room.tags.keys.contains(AppConfig.canalTag)) .where((room) => !room.isLeftOrBanned)
.where((room) => room.isCanal)
.where( .where(
(room) => (room) =>
searchText.isEmpty || searchText.isEmpty ||

View File

@@ -5,7 +5,6 @@
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart'; import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat_list/chat_list.dart'; import 'package:tatchat/pages/chat_list/chat_list.dart';
import 'package:tatchat/pages/chat_list/chat_list_item.dart'; import 'package:tatchat/pages/chat_list/chat_list_item.dart';
@@ -13,6 +12,7 @@ import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart';
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart'; import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/stream_extension.dart';
import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/matrix.dart';
@@ -63,7 +63,8 @@ class _MuralsTabState extends State<MuralsTab> {
.rateLimit(const Duration(seconds: 1)), .rateLimit(const Duration(seconds: 1)),
builder: (context, _) { builder: (context, _) {
final murals = client.rooms final murals = client.rooms
.where((room) => room.tags.keys.contains(AppConfig.muralTag)) .where((room) => !room.isLeftOrBanned)
.where((room) => room.isMural)
.where( .where(
(room) => (room) =>
searchText.isEmpty || searchText.isEmpty ||
@@ -90,11 +91,24 @@ class _MuralsTabState extends State<MuralsTab> {
itemCount: murals.length, itemCount: murals.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final room = murals[i]; final room = murals[i];
final creatorId = room.creatorUserIds.isEmpty
? null
: room.creatorUserIds.first;
final creatorState = creatorId == null
? null
: room.getState(EventTypes.RoomMember, creatorId);
return ChatListItem( return ChatListItem(
room, room,
key: Key('mural_list_item_${room.id}'), key: Key('mural_list_item_${room.id}'),
onTap: () => widget.controller.onChatTap(room), onTap: () => widget.controller.onChatTap(room),
activeChat: widget.controller.activeChat == room.id, activeChat: widget.controller.activeChat == room.id,
displayAvatar: Uri.tryParse(
creatorState?.content.tryGet<String>('avatar_url') ??
'',
),
displayAvatarName: creatorState?.content.tryGet<String>(
'displayname',
),
); );
}, },
), ),

View File

@@ -3,20 +3,27 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // 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:go_router/go_router.dart';
import 'package:image_picker/image_picker.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/l10n/l10n.dart';
import 'package:tatchat/pages/chat/send_file_dialog.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/pages/image_viewer/video_player.dart';
import 'package:tatchat/utils/adaptive_bottom_sheet.dart'; import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/stream_extension.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/show_text_input_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/user_dialog.dart';
import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/mxc_image.dart'; import 'package:tatchat/widgets/mxc_image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
const _muralMediaTypes = { const _muralMediaTypes = {
MessageTypes.Image, MessageTypes.Image,
@@ -68,7 +75,10 @@ class _MuralChatViewState extends State<MuralChatView> {
? const [] ? const []
: timeline!.events : timeline!.events
.where( .where(
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType), (e) =>
!e.redacted &&
e.relationshipType == null &&
_muralMediaTypes.contains(e.messageType),
) )
.toList(); .toList();
@@ -340,7 +350,6 @@ class _Header extends StatelessWidget {
final creatorAvatar = Uri.tryParse( final creatorAvatar = Uri.tryParse(
creatorState?.content.tryGet<String>('avatar_url') ?? '', creatorState?.content.tryGet<String>('avatar_url') ?? '',
); );
final topic = room.topic.trim();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -406,10 +415,6 @@ class _Header extends StatelessWidget {
left: 16 + avatarSize + 12, left: 16 + avatarSize + 12,
right: 8, right: 8,
bottom: -avatarSize / 2, bottom: -avatarSize / 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text( child: Text(
@@ -430,27 +435,32 @@ class _Header extends StatelessWidget {
), ),
), ),
), ),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35), color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
tooltip: l10n.settings, tooltip: l10n.settings,
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
color: Colors.white,
onPressed: onEditInfo, onPressed: onEditInfo,
), ),
), ),
], ],
), ),
), ),
],
),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4), padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: _ExpandableDescription( child: _MuralDescription(
text: topic, room: room,
text: room.topic,
expanded: descriptionExpanded, expanded: descriptionExpanded,
onToggle: onToggleDescription, 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 String? text;
final bool expanded; final bool expanded;
final VoidCallback onToggle; final VoidCallback onToggle;
const _ExpandableDescription({ const _MuralDescription({
required this.room,
required this.text, required this.text,
required this.expanded, required this.expanded,
required this.onToggle, required this.onToggle,
@@ -478,10 +490,54 @@ class _ExpandableDescription extends StatelessWidget {
final text = this.text?.trim(); final text = this.text?.trim();
if (text == null || text.isEmpty) return const SizedBox.shrink(); if (text == null || text.isEmpty) return const SizedBox.shrink();
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4); 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final painter = TextPainter( final painter = TextPainter(
text: TextSpan(text: text, style: style), text: buildTextSpan(elements, style: style, linkStyle: linkStyle),
maxLines: 4, maxLines: 4,
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth); )..layout(maxWidth: constraints.maxWidth);
@@ -489,12 +545,7 @@ class _ExpandableDescription extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( richText(),
text,
style: style,
maxLines: expanded ? null : 4,
overflow: expanded ? null : TextOverflow.ellipsis,
),
if (exceeds) if (exceeds)
TextButton( TextButton(
onPressed: onToggle, onPressed: onToggle,
@@ -671,6 +722,50 @@ class _PostViewerState extends State<_PostViewer> {
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId); 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 { Future<void> _sendComment() async {
final text = _commentController.text.trim(); final text = _commentController.text.trim();
if (text.isEmpty || _sending) return; 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() { List<Event> _comments() {
final comments = widget.timeline.events final comments = widget.timeline.events
.where( .where(
@@ -696,6 +871,18 @@ class _PostViewerState extends State<_PostViewer> {
return comments; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -772,11 +959,13 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
IconButton( IconButton(
tooltip: l10n.editCaption, tooltip: liked ? l10n.unlike : l10n.like,
icon: const Icon(Icons.edit_outlined), icon: Icon(
onPressed: _editCaption, liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.redAccent : null,
),
onPressed: _toggleLike,
), ),
const Spacer(),
if (_likeCount > 0) if (_likeCount > 0)
Padding( Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
@@ -787,13 +976,46 @@ class _PostViewerState extends State<_PostViewer> {
), ),
), ),
), ),
IconButton( const Spacer(),
tooltip: liked ? l10n.unlike : l10n.like, PopupMenuButton<String>(
icon: Icon( tooltip: l10n.more,
liked ? Icons.favorite : Icons.favorite_border, onSelected: (action) async {
color: liked ? Colors.redAccent : null, 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,
), ),
onPressed: _toggleLike, ),
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, itemCount: comments.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final comment = comments[i]; final comment = comments[i];
final isSticker =
comment.messageType == MessageTypes.Sticker;
final sender = comment.senderFromMemoryOrFallback;
return Padding( return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
@@ -835,30 +1060,41 @@ class _PostViewerState extends State<_PostViewer> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Avatar( GestureDetector(
mxContent: comment onTap: () => _openProfile(comment),
.senderFromMemoryOrFallback child: Avatar(
.avatarUrl, mxContent: sender.avatarUrl,
name: comment.senderFromMemoryOrFallback name: sender.calcDisplayname(),
.calcDisplayname(),
size: 32, size: 32,
client: widget.room.client, client: widget.room.client,
), ),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
Text( GestureDetector(
comment.senderFromMemoryOrFallback onTap: () => _openProfile(comment),
.calcDisplayname(), child: Text(
sender.calcDisplayname(),
style: theme.textTheme.labelMedium style: theme.textTheme.labelMedium
?.copyWith( ?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
),
const SizedBox(height: 2), const SizedBox(height: 2),
if (isSticker)
MxcImage(
key: ValueKey(comment.eventId),
event: comment,
fit: BoxFit.contain,
isThumbnail: false,
width: double.infinity,
)
else
Text( Text(
comment comment
.getDisplayEvent(widget.timeline) .getDisplayEvent(widget.timeline)
@@ -879,9 +1115,19 @@ class _PostViewerState extends State<_PostViewer> {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8), padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Row( child: Row(
children: [ 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( Expanded(
child: TextField( child: TextField(
controller: _commentController, controller: _commentController,

View File

@@ -11,6 +11,7 @@ import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/new_group/new_group_view.dart'; import 'package:tatchat/pages/new_group/new_group_view.dart';
import 'package:tatchat/utils/file_selector.dart'; import 'package:tatchat/utils/file_selector.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart'; import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/widgets/matrix.dart'; import 'package:tatchat/widgets/matrix.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@@ -141,8 +142,10 @@ class NewGroupController extends State<NewGroup> {
: sdk.CreateRoomPreset.privateChat, : sdk.CreateRoomPreset.privateChat,
groupName: nameController.text.isNotEmpty ? nameController.text : null, groupName: nameController.text.isNotEmpty ? nameController.text : null,
powerLevelContentOverride: { powerLevelContentOverride: {
// Only admins (power level 100) are allowed to post in a canal. // All members may send messages so they can react and comment in the
'events_default': 100, // publication threads. The app hides the publication composer for
// non-admins in the main canal feed instead.
'events_default': 0,
}, },
initialState: [ initialState: [
if (avatar != null) if (avatar != null)
@@ -155,6 +158,7 @@ class NewGroupController extends State<NewGroup> {
final canal = client.getRoomById(roomId); final canal = client.getRoomById(roomId);
if (canal != null) { if (canal != null) {
await canal.addTag(AppConfig.canalTag); await canal.addTag(AppConfig.canalTag);
await canal.setRoomType('canal');
} }
await _addToSpace(roomId); await _addToSpace(roomId);
if (!mounted) return; if (!mounted) return;
@@ -190,6 +194,7 @@ class NewGroupController extends State<NewGroup> {
final mural = client.getRoomById(roomId); final mural = client.getRoomById(roomId);
if (mural != null) { if (mural != null) {
await mural.addTag(AppConfig.muralTag); await mural.addTag(AppConfig.muralTag);
await mural.setRoomType('mural');
} }
await _addToSpace(roomId); await _addToSpace(roomId);
if (!mounted) return; if (!mounted) return;

View File

@@ -11,6 +11,7 @@ import 'package:tatchat/utils/custom_http_client.dart';
import 'package:tatchat/utils/custom_image_resizer.dart'; import 'package:tatchat/utils/custom_image_resizer.dart';
import 'package:tatchat/utils/init_with_restore.dart'; import 'package:tatchat/utils/init_with_restore.dart';
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart'; import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
import 'package:tatchat/utils/platform_infos.dart'; import 'package:tatchat/utils/platform_infos.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@@ -130,6 +131,9 @@ abstract class ClientManager {
'im.ponies.room_emotes', 'im.ponies.room_emotes',
// To display call state in chat list // To display call state in chat list
MatrixRtcCallMember.eventType, MatrixRtcCallMember.eventType,
// Make the canal/mural room type marker available in the chat list
// right after sync, even for rooms that are still partially loaded.
roomTypeStateEvent,
}, },
customImageResizer: PlatformInfos.supportsCustomImageResizer customImageResizer: PlatformInfos.supportsCustomImageResizer
? customImageResizer ? customImageResizer

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:matrix/matrix.dart';
import 'package:tatchat/config/app_config.dart';
/// State event type that marks the room category (canal/mural) on a room
/// level. Unlike the personal tags (per-account data), this marker is shared
/// by all room members which makes the categorization consistent across
/// different accounts.
const String roomTypeStateEvent = 'tatuchat.room_type';
extension RoomTypeExtension on Room {
String? get roomType =>
getState(roomTypeStateEvent)?.content.tryGet<String>('type');
bool get isCanal =>
tags.keys.contains(AppConfig.canalTag) || roomType == 'canal';
bool get isMural =>
tags.keys.contains(AppConfig.muralTag) || roomType == 'mural';
/// Whether the account left (or was banned from) this room. Such rooms must
/// not be shown in the chat list, even if a stale local copy with an old
/// "join" membership reappears after a restart.
bool get isLeftOrBanned =>
{Membership.leave, Membership.ban}.contains(membership);
Future<String> setRoomType(String type) =>
client.setRoomStateWithKey(id, roomTypeStateEvent, '', {'type': type});
}

View File

@@ -27,6 +27,8 @@ Future<OkCancelResult?> showOkCancelAlertDialog({
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: Text(title), child: Text(title),
), ),
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: message == null child: message == null
@@ -79,6 +81,8 @@ Future<OkCancelResult?> showOkAlertDialog({
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: Text(title), child: Text(title),
), ),
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: message == null child: message == null

View File

@@ -43,6 +43,8 @@ Future<String?> showTextInputDialog({
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: Text(title), child: Text(title),
), ),
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 256), constraints: const BoxConstraints(maxWidth: 256),
child: Column( child: Column(

View File

@@ -1157,7 +1157,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
linkify: linkify:
dependency: transitive dependency: "direct main"
description: description:
name: linkify name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"

View File

@@ -58,6 +58,7 @@ dependencies:
just_audio_media_kit: ^2.1.0 just_audio_media_kit: ^2.1.0
latlong2: ^0.10.1 latlong2: ^0.10.1
light_compressor_v2: ^1.9.1 light_compressor_v2: ^1.9.1
linkify: ^5.0.0
livekit_client: ^2.11.0 livekit_client: ^2.11.0
local_auth: ^3.0.2 local_auth: ^3.0.2
lottie: ^3.5.1 lottie: ^3.5.1