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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,21 +152,27 @@ 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
topLeft: !ownMessage ? hardCorner : roundedCorner, ? BorderRadius.all(roundedCorner)
topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner, : BorderRadius.only(
bottomLeft: !ownMessage && previousEventSameSender topLeft: !ownMessage ? hardCorner : roundedCorner,
? hardCorner topRight: ownMessage && nextEventSameSender
: roundedCorner, ? hardCorner
bottomRight: ownMessage ? hardCorner : roundedCorner, : roundedCorner,
); bottomLeft: !ownMessage && previousEventSameSender
? hardCorner
: roundedCorner,
bottomRight: ownMessage ? hardCorner : roundedCorner,
);
const avatarSize = Avatar.defaultSize; const avatarSize = Avatar.defaultSize;
final noBubble = final noBubble =
({ ({
@@ -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,70 +346,76 @@ class Message extends StatelessWidget {
crossAxisAlignment: .start, crossAxisAlignment: .start,
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
Padding( if (!card)
padding: EdgeInsets.only(left: 8.0), Padding(
child: Row( padding: EdgeInsets.only(left: 8.0),
mainAxisAlignment: ownMessage ? .end : .start, child: Row(
children: [ mainAxisAlignment: card
if (sender.powerLevel.role != ? .start
PowerLevelRole.user && : ownMessage
!nextEventSameSender && ? .end
!ownMessage && : .start,
!event.room.isDirectChat) children: [
Padding( if (sender.powerLevel.role !=
padding: const EdgeInsets.only( PowerLevelRole.user &&
right: 2.0, !nextEventSameSender &&
!ownMessage &&
!event.room.isDirectChat)
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,
),
), ),
child: Icon( if ((!nextEventSameSender) && !ownMessage)
sender.powerLevel.role == FutureBuilder<User?>(
PowerLevelRole.moderator future: event.fetchSenderUser(),
? Icons.add_moderator_outlined builder: (context, snapshot) {
: Icons.admin_panel_settings, final displayname =
size: 14, snapshot.data
color: theme ?.calcDisplayname() ??
.colorScheme sender.calcDisplayname();
.onPrimaryContainer, return ConstrainedBox(
), constraints: BoxConstraints(
), maxWidth: 200,
if ((!nextEventSameSender) && !ownMessage)
FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final displayname =
snapshot.data?.calcDisplayname() ??
sender.calcDisplayname();
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 200,
),
child: Text(
displayname,
style: TextStyle(
fontWeight: FontWeight.bold,
color: event.room.isDirectChat
? Colors.transparent
: (theme.brightness ==
Brightness.light
? displayname
.colorScheme
.primary
: displayname
.colorScheme
.primaryContainer),
fontSize: 11,
shadows: event.room.isDirectChat
? null
: wallpaperTextShadow,
), ),
maxLines: 1, child: Text(
overflow: TextOverflow.ellipsis, displayname,
), style: TextStyle(
); fontWeight: FontWeight.bold,
}, color: event.room.isDirectChat
), ? Colors.transparent
], : (theme.brightness ==
Brightness.light
? displayname
.colorScheme
.primary
: displayname
.colorScheme
.primaryContainer),
fontSize: 11,
shadows: event.room.isDirectChat
? null
: wallpaperTextShadow,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
),
],
),
), ),
),
Container( Container(
alignment: alignment, alignment: alignment,
@@ -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 ||
@@ -105,4 +106,4 @@ class _ChannelsTabState extends State<ChannelsTab> {
), ),
); );
} }
} }

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,51 +415,52 @@ class _Header extends StatelessWidget {
left: 16 + avatarSize + 12, left: 16 + avatarSize + 12,
right: 8, right: 8,
bottom: -avatarSize / 2, bottom: -avatarSize / 2,
child: Row( child: Padding(
crossAxisAlignment: CrossAxisAlignment.end, padding: const EdgeInsets.symmetric(horizontal: 8),
children: [ child: Text(
Expanded( displayName,
child: Padding( maxLines: 2,
padding: const EdgeInsets.symmetric(horizontal: 8), overflow: TextOverflow.ellipsis,
child: Text( style: theme.textTheme.titleLarge?.copyWith(
displayName, fontWeight: FontWeight.bold,
maxLines: 2, color: Colors.white,
overflow: TextOverflow.ellipsis, shadows: const [
style: theme.textTheme.titleLarge?.copyWith( Shadow(
fontWeight: FontWeight.bold, color: Colors.black54,
color: Colors.white, blurRadius: 6,
shadows: const [ offset: Offset(0, 1),
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(
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4), padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
child: _ExpandableDescription( child: Row(
text: topic, 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, 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':
onPressed: _toggleLike, 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, 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,14 +1060,14 @@ 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(
@@ -850,25 +1075,36 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
Text( GestureDetector(
comment.senderFromMemoryOrFallback onTap: () => _openProfile(comment),
.calcDisplayname(), child: Text(
style: theme.textTheme.labelMedium sender.calcDisplayname(),
?.copyWith( style: theme.textTheme.labelMedium
fontWeight: FontWeight.bold, ?.copyWith(
), fontWeight: FontWeight.bold,
),
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( if (isSticker)
comment MxcImage(
.getDisplayEvent(widget.timeline) key: ValueKey(comment.eventId),
.calcLocalizedBodyFallback( event: comment,
MatrixLocals(l10n), fit: BoxFit.contain,
withSenderNamePrefix: false, isThumbnail: false,
hideReply: true, width: double.infinity,
), )
style: theme.textTheme.bodyMedium, 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(
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