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

View File

@@ -56,6 +56,17 @@ abstract class FluffyThemes {
brightness: brightness,
colorScheme: colorScheme,
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(
style: SegmentedButton.styleFrom(
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,
future: () async {
Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}');
await archive[i].room.forget();
await forgetRoom(archive[i].room);
archive.removeAt(i);
},
);
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 {
final archive = this.archive;
if (archive.isEmpty) return;
@@ -62,7 +81,7 @@ class ArchiveController extends State<Archive> {
Logs().v(
'Forget room ${archive.last.room.getLocalizedDisplayname()}',
);
await archive.last.room.forget();
await forgetRoom(archive.last.room);
archive.removeLast();
}
},

View File

@@ -10,7 +10,6 @@ import 'package:collection/collection.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.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/themes.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/filtered_timeline_extension.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/platform_infos.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);
}

View File

@@ -22,6 +22,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import '../../config/app_config.dart';
import '../../utils/date_time_extension.dart';
import '../../utils/matrix_sdk_extensions/room_extension.dart';
class ChatEventList extends StatelessWidget {
final ChatController controller;
@@ -237,6 +238,9 @@ class ChatEventList extends StatelessWidget {
),
)
: 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:matrix/matrix.dart';
import '../../utils/matrix_sdk_extensions/room_extension.dart';
import '../../utils/stream_extension.dart';
import 'chat_emoji_picker.dart';
import 'chat_input_row.dart';
@@ -403,7 +404,13 @@ class ChatView extends StatelessWidget {
)
: controller.room.canSendDefaultMessages &&
controller.room.membership ==
Membership.join
Membership.join &&
(controller.room.isCanal &&
controller.activeThreadId ==
null
? controller.room.ownPowerLevel >=
PowerLevel.admin
: true)
? Material(
clipBehavior: Clip.hardEdge,
color:

View File

@@ -55,6 +55,12 @@ class Message extends StatelessWidget {
final bool isCollapsed;
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(
this.event, {
this.nextEvent,
@@ -79,6 +85,7 @@ class Message extends StatelessWidget {
this.onExpand,
required this.enterThread,
this.isCollapsed = false,
this.fullWidthCard = false,
super.key,
});
@@ -105,7 +112,12 @@ class Message extends StatelessWidget {
final client = Matrix.of(context).client;
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;
final displayTime =
@@ -128,6 +140,10 @@ class Message extends StatelessWidget {
previousEvent!.senderId == event.senderId &&
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
? theme.onBubbleColor
: theme.colorScheme.onSurface;
@@ -136,21 +152,27 @@ class Message extends StatelessWidget {
? theme.colorScheme.onTertiaryContainer
: theme.colorScheme.tertiary;
final rowMainAxisAlignment = ownMessage
final rowMainAxisAlignment = card
? MainAxisAlignment.start
: ownMessage
? MainAxisAlignment.end
: MainAxisAlignment.start;
final displayEvent = event.getDisplayEvent(timeline);
const hardCorner = Radius.circular(3);
const roundedCorner = Radius.circular(AppConfig.borderRadius);
final borderRadius = BorderRadius.only(
topLeft: !ownMessage ? hardCorner : roundedCorner,
topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner,
bottomLeft: !ownMessage && previousEventSameSender
? hardCorner
: roundedCorner,
bottomRight: ownMessage ? hardCorner : roundedCorner,
);
final borderRadius = card
? BorderRadius.all(roundedCorner)
: BorderRadius.only(
topLeft: !ownMessage ? hardCorner : roundedCorner,
topRight: ownMessage && nextEventSameSender
? hardCorner
: roundedCorner,
bottomLeft: !ownMessage && previousEventSameSender
? hardCorner
: roundedCorner,
bottomRight: ownMessage ? hardCorner : roundedCorner,
);
const avatarSize = Avatar.defaultSize;
final noBubble =
({
@@ -229,9 +251,9 @@ class Message extends StatelessWidget {
: SwipeDirection.startToEnd,
onSwipe: (_) => onSwipe(),
child: Container(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.maxTimelineWidth,
),
constraints: card
? const BoxConstraints()
: const BoxConstraints(maxWidth: FluffyThemes.maxTimelineWidth),
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
@@ -242,7 +264,11 @@ class Message extends StatelessWidget {
),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: ownMessage ? .end : .start,
crossAxisAlignment: card
? CrossAxisAlignment.stretch
: ownMessage
? .end
: .start,
children: <Widget>[
Stack(
clipBehavior: Clip.none,
@@ -275,7 +301,9 @@ class Message extends StatelessWidget {
crossAxisAlignment: .start,
mainAxisAlignment: rowMainAxisAlignment,
children: [
if (longPressSelect && !event.redacted)
if (card)
const SizedBox.shrink()
else if (longPressSelect && !event.redacted)
SizedBox(
height: avatarSize,
width: avatarSize,
@@ -318,70 +346,76 @@ class Message extends StatelessWidget {
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Row(
mainAxisAlignment: ownMessage ? .end : .start,
children: [
if (sender.powerLevel.role !=
PowerLevelRole.user &&
!nextEventSameSender &&
!ownMessage &&
!event.room.isDirectChat)
Padding(
padding: const EdgeInsets.only(
right: 2.0,
if (!card)
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Row(
mainAxisAlignment: card
? .start
: ownMessage
? .end
: .start,
children: [
if (sender.powerLevel.role !=
PowerLevelRole.user &&
!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(
sender.powerLevel.role ==
PowerLevelRole.moderator
? Icons.add_moderator_outlined
: Icons.admin_panel_settings,
size: 14,
color: theme
.colorScheme
.onPrimaryContainer,
),
),
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,
if ((!nextEventSameSender) && !ownMessage)
FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final displayname =
snapshot.data
?.calcDisplayname() ??
sender.calcDisplayname();
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 200,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
),
],
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,
overflow: TextOverflow.ellipsis,
),
);
},
),
],
),
),
),
Container(
alignment: alignment,
@@ -438,6 +472,7 @@ class Message extends StatelessWidget {
),
animateIn: animateIn,
child: Container(
width: card ? double.infinity : null,
decoration: BoxDecoration(
color: noBubble
? Colors.transparent
@@ -458,15 +493,108 @@ class Message extends StatelessWidget {
AppConfig.borderRadius,
),
),
constraints: const BoxConstraints(
maxWidth:
FluffyThemes.columnWidth * 1.5,
constraints: BoxConstraints(
maxWidth: card
? double.infinity
: FluffyThemes.columnWidth * 1.5,
),
child: Column(
mainAxisSize: .min,
crossAxisAlignment:
CrossAxisAlignment.start,
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(
includingFallback: false,
) !=
@@ -557,7 +685,9 @@ class Message extends StatelessWidget {
child: !hasReactions
? const SizedBox.shrink()
: Container(
alignment: ownMessage
alignment: card
? Alignment.centerLeft
: ownMessage
? Alignment.centerRight
: Alignment.centerLeft,
padding: EdgeInsets.only(
@@ -569,7 +699,11 @@ class Message extends StatelessWidget {
),
),
Row(
mainAxisAlignment: ownMessage ? .end : .start,
mainAxisAlignment: card
? .start
: ownMessage
? .end
: .start,
children: [
const SizedBox(width: 8),
if (event.status.isSent &&
@@ -643,7 +777,9 @@ class Message extends StatelessWidget {
],
),
Align(
alignment: ownMessage
alignment: card
? Alignment.bottomLeft
: ownMessage
? Alignment.bottomRight
: Alignment.bottomLeft,
child: AnimatedSize(
@@ -861,19 +997,24 @@ class Message extends StatelessWidget {
child: threadChildren.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(
top: 2.0,
bottom: 8.0,
left: avatarSize + 8,
),
padding: const EdgeInsets.only(top: 2.0, bottom: 8.0)
.add(
EdgeInsets.only(
left: card ? 8.0 : avatarSize + 8,
),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 1.5,
constraints: BoxConstraints(
maxWidth: card
? double.infinity
: FluffyThemes.columnWidth * 1.5,
),
child: TextButton.icon(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
foregroundColor:
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/localized_exception_extension.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/show_scaffold_dialog.dart';
import 'package:tatchat/utils/show_update_snackbar.dart';
@@ -402,6 +403,7 @@ class ChatListController extends State<ChatList>
}
StreamSubscription? _onRoomTagUpdate;
StreamSubscription? _backfillSubscription;
@override
void initState() {
@@ -411,6 +413,8 @@ class ChatListController extends State<ChatList>
scrollController.addListener(_onScroll);
_waitForFirstSync();
_backfillSubscription =
_clientStream.stream.listen((_) => backfillRoomTypeMarkers());
if (PlatformInfos.isMobile) {
_callEventSubscription = FlutterCallkitIncoming.onEvent.listen(
_onCallEvent,
@@ -474,6 +478,7 @@ class ChatListController extends State<ChatList>
_intentFileStreamSubscription?.cancel();
_callEventSubscription?.cancel();
_onRoomTagUpdate?.cancel();
_backfillSubscription?.cancel();
scrollController.removeListener(_onScroll);
searchController.dispose();
searchFocusNode.dispose();
@@ -775,7 +780,10 @@ class ChatListController extends State<ChatList>
if (confirmed == OkCancelResult.cancel) return;
if (!mounted) return;
await showFutureLoadingDialog(context: context, future: room.leave);
await showFutureLoadingDialog(
context: context,
future: () => leaveRoom(room),
);
return;
case ChatContextAction.addToSpace:
@@ -901,6 +909,65 @@ class ChatListController extends State<ChatList>
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 {
final router = GoRouter.of(context);
final client = Matrix.of(context).client;
@@ -921,6 +988,7 @@ class ChatListController extends State<ChatList>
setState(() {
waitForFirstSync = true;
});
backfillRoomTypeMarkers();
if (client.userDeviceKeys[client.userID!]?.deviceKeys.values.any(
(device) => !device.verified && !device.blocked,

View File

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

View File

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

View File

@@ -5,7 +5,6 @@
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat_list/chat_list.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/home_tab.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/widgets/matrix.dart';
@@ -63,7 +63,8 @@ class _ChannelsTabState extends State<ChannelsTab> {
.rateLimit(const Duration(seconds: 1)),
builder: (context, _) {
final channels = client.rooms
.where((room) => room.tags.keys.contains(AppConfig.canalTag))
.where((room) => !room.isLeftOrBanned)
.where((room) => room.isCanal)
.where(
(room) =>
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:matrix/matrix.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat_list/chat_list.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/home_tab.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/widgets/matrix.dart';
@@ -63,7 +63,8 @@ class _MuralsTabState extends State<MuralsTab> {
.rateLimit(const Duration(seconds: 1)),
builder: (context, _) {
final murals = client.rooms
.where((room) => room.tags.keys.contains(AppConfig.muralTag))
.where((room) => !room.isLeftOrBanned)
.where((room) => room.isMural)
.where(
(room) =>
searchText.isEmpty ||
@@ -90,11 +91,24 @@ class _MuralsTabState extends State<MuralsTab> {
itemCount: murals.length,
itemBuilder: (context, 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(
room,
key: Key('mural_list_item_${room.id}'),
onTap: () => widget.controller.onChatTap(room),
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
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:linkify/linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat/send_file_dialog.dart';
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
import 'package:tatchat/pages/image_viewer/video_player.dart';
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/utils/stream_extension.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/user_dialog.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
const _muralMediaTypes = {
MessageTypes.Image,
@@ -68,7 +75,10 @@ class _MuralChatViewState extends State<MuralChatView> {
? const []
: timeline!.events
.where(
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType),
(e) =>
!e.redacted &&
e.relationshipType == null &&
_muralMediaTypes.contains(e.messageType),
)
.toList();
@@ -340,7 +350,6 @@ class _Header extends StatelessWidget {
final creatorAvatar = Uri.tryParse(
creatorState?.content.tryGet<String>('avatar_url') ?? '',
);
final topic = room.topic.trim();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -406,51 +415,52 @@ class _Header extends StatelessWidget {
left: 16 + avatarSize + 12,
right: 8,
bottom: -avatarSize / 2,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
displayName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black54,
blurRadius: 6,
offset: Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
displayName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(
color: Colors.black54,
blurRadius: 6,
offset: Offset(0, 1),
),
),
],
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: IconButton(
tooltip: l10n.settings,
icon: const Icon(Icons.more_vert),
color: Colors.white,
onPressed: onEditInfo,
),
),
],
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4),
child: _ExpandableDescription(
text: topic,
padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: IconButton(
tooltip: l10n.settings,
icon: const Icon(Icons.more_vert),
onPressed: onEditInfo,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
child: _MuralDescription(
room: room,
text: room.topic,
expanded: descriptionExpanded,
onToggle: onToggleDescription,
),
@@ -460,12 +470,14 @@ class _Header extends StatelessWidget {
}
}
class _ExpandableDescription extends StatelessWidget {
class _MuralDescription extends StatelessWidget {
final Room room;
final String? text;
final bool expanded;
final VoidCallback onToggle;
const _ExpandableDescription({
const _MuralDescription({
required this.room,
required this.text,
required this.expanded,
required this.onToggle,
@@ -478,10 +490,54 @@ class _ExpandableDescription extends StatelessWidget {
final text = this.text?.trim();
if (text == null || text.isEmpty) return const SizedBox.shrink();
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4);
final linkStyle = TextStyle(
color: theme.colorScheme.primary,
decorationColor: theme.colorScheme.primary,
decoration: TextDecoration.underline,
);
const linkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()];
final elements = linkify(text, linkifiers: linkifiers);
Future<void> openLink(LinkableElement rawLink) async {
final url = rawLink.url;
if (rawLink is UserTagElement) {
final userTag = rawLink.userTag;
var mentionId = userTag.startsWith('@')
? userTag.substring(1)
: userTag;
if (!mentionId.contains(':')) {
mentionId = '$mentionId:${room.client.homeserver?.host ?? ''}';
}
final memberState = room.getState(EventTypes.RoomMember, mentionId);
final profile = Profile(
userId: mentionId,
displayName:
memberState?.content.tryGet<String>('displayname') ?? userTag,
avatarUrl: Uri.tryParse(
memberState?.content.tryGet<String>('avatar_url') ?? '',
),
);
await UserDialog.show(context: context, profile: profile);
return;
}
UrlLauncher(context, url).launchUrl();
}
Widget richText({bool measure = false}) => Text.rich(
buildTextSpan(
elements,
style: style,
linkStyle: linkStyle,
onOpen: openLink,
),
maxLines: expanded ? null : 4,
overflow: expanded ? null : TextOverflow.ellipsis,
);
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
text: buildTextSpan(elements, style: style, linkStyle: linkStyle),
maxLines: 4,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
@@ -489,12 +545,7 @@ class _ExpandableDescription extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
text,
style: style,
maxLines: expanded ? null : 4,
overflow: expanded ? null : TextOverflow.ellipsis,
),
richText(),
if (exceeds)
TextButton(
onPressed: onToggle,
@@ -671,6 +722,50 @@ class _PostViewerState extends State<_PostViewer> {
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId);
}
Future<void> _deletePost() async {
final l10n = L10n.of(context);
final delete = await showOkCancelAlertDialog(
context: context,
title: l10n.areYouSure,
message: l10n.areYouSureDeletePost,
okLabel: l10n.delete,
cancelLabel: l10n.cancel,
);
if (delete != OkCancelResult.ok || !mounted) return;
try {
await event.redactEvent();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)),
);
return;
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _reportPost() async {
final l10n = L10n.of(context);
final reason = await showTextInputDialog(
context: context,
title: l10n.whyDoYouWantToReportThis,
okLabel: l10n.report,
cancelLabel: l10n.cancel,
hintText: l10n.reason,
);
if (reason == null || reason.isEmpty || !mounted) return;
await widget.room.client.reportEvent(
widget.room.id,
event.eventId,
reason: reason,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.contentHasBeenReported)));
}
Future<void> _sendComment() async {
final text = _commentController.text.trim();
if (text.isEmpty || _sending) return;
@@ -683,6 +778,86 @@ class _PostViewerState extends State<_PostViewer> {
}
}
void _typeEmoji(Emoji? emoji) {
if (emoji == null) return;
final emojiText = emoji.emoji;
final value = _commentController.value;
if (value.selection.start != -1 && value.selection.isValid) {
final newText = value.text.replaceRange(
value.selection.start,
value.selection.end,
emojiText,
);
_commentController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: value.selection.start + emojiText.characters.length,
),
);
} else {
_commentController.text += emojiText;
}
}
Future<void> _showEmojiPicker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
backgroundColor: theme.colorScheme.surface,
builder: (context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.5,
child: EmojiPicker(
onEmojiSelected: (_, emoji) {
_typeEmoji(emoji);
},
config: Config(
locale: Localizations.localeOf(context),
emojiViewConfig: EmojiViewConfig(
backgroundColor: theme.colorScheme.onInverseSurface,
),
bottomActionBarConfig: const BottomActionBarConfig(enabled: false),
categoryViewConfig: CategoryViewConfig(
backspaceColor: theme.colorScheme.primary,
iconColor: theme.colorScheme.primary.withAlpha(128),
iconColorSelected: theme.colorScheme.primary,
indicatorColor: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surface,
),
skinToneConfig: const SkinToneConfig(indicatorColor: Colors.grey),
),
),
),
);
}
Future<void> _sendSticker() async {
final theme = Theme.of(context);
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
backgroundColor: theme.colorScheme.surface,
builder: (context) => SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
child: StickerPickerDialog(
room: widget.room,
onSelected: (sticker) {
Navigator.of(context, rootNavigator: true).pop();
widget.room.sendEvent(
{
'body': sticker.body,
'info': sticker.info ?? {},
'url': sticker.url.toString(),
},
type: EventTypes.Sticker,
threadRootEventId: event.eventId,
);
},
),
),
);
}
List<Event> _comments() {
final comments = widget.timeline.events
.where(
@@ -696,6 +871,18 @@ class _PostViewerState extends State<_PostViewer> {
return comments;
}
void _openProfile(Event comment) {
final sender = comment.senderFromMemoryOrFallback;
UserDialog.show(
context: context,
profile: Profile(
userId: sender.id,
displayName: sender.calcDisplayname(),
avatarUrl: sender.avatarUrl,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -772,11 +959,13 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
tooltip: l10n.editCaption,
icon: const Icon(Icons.edit_outlined),
onPressed: _editCaption,
tooltip: liked ? l10n.unlike : l10n.like,
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.redAccent : null,
),
onPressed: _toggleLike,
),
const Spacer(),
if (_likeCount > 0)
Padding(
padding: const EdgeInsets.only(bottom: 10),
@@ -787,13 +976,46 @@ class _PostViewerState extends State<_PostViewer> {
),
),
),
IconButton(
tooltip: liked ? l10n.unlike : l10n.like,
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.redAccent : null,
),
onPressed: _toggleLike,
const Spacer(),
PopupMenuButton<String>(
tooltip: l10n.more,
onSelected: (action) async {
switch (action) {
case 'edit':
await _editCaption();
case 'delete':
await _deletePost();
case 'report':
await _reportPost();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(l10n.editCaption),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'delete',
child: ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(l10n.delete),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'report',
child: ListTile(
leading: const Icon(Icons.flag_outlined),
title: Text(l10n.report),
contentPadding: EdgeInsets.zero,
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
@@ -827,6 +1049,9 @@ class _PostViewerState extends State<_PostViewer> {
itemCount: comments.length,
itemBuilder: (context, i) {
final comment = comments[i];
final isSticker =
comment.messageType == MessageTypes.Sticker;
final sender = comment.senderFromMemoryOrFallback;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -835,14 +1060,14 @@ class _PostViewerState extends State<_PostViewer> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
mxContent: comment
.senderFromMemoryOrFallback
.avatarUrl,
name: comment.senderFromMemoryOrFallback
.calcDisplayname(),
size: 32,
client: widget.room.client,
GestureDetector(
onTap: () => _openProfile(comment),
child: Avatar(
mxContent: sender.avatarUrl,
name: sender.calcDisplayname(),
size: 32,
client: widget.room.client,
),
),
const SizedBox(width: 8),
Expanded(
@@ -850,25 +1075,36 @@ class _PostViewerState extends State<_PostViewer> {
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
comment.senderFromMemoryOrFallback
.calcDisplayname(),
style: theme.textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
GestureDetector(
onTap: () => _openProfile(comment),
child: Text(
sender.calcDisplayname(),
style: theme.textTheme.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
Text(
comment
.getDisplayEvent(widget.timeline)
.calcLocalizedBodyFallback(
MatrixLocals(l10n),
withSenderNamePrefix: false,
hideReply: true,
),
style: theme.textTheme.bodyMedium,
),
if (isSticker)
MxcImage(
key: ValueKey(comment.eventId),
event: comment,
fit: BoxFit.contain,
isThumbnail: false,
width: double.infinity,
)
else
Text(
comment
.getDisplayEvent(widget.timeline)
.calcLocalizedBodyFallback(
MatrixLocals(l10n),
withSenderNamePrefix: false,
hideReply: true,
),
style: theme.textTheme.bodyMedium,
),
],
),
),
@@ -879,9 +1115,19 @@ class _PostViewerState extends State<_PostViewer> {
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Row(
children: [
IconButton(
tooltip: l10n.emojis,
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: _showEmojiPicker,
),
IconButton(
tooltip: l10n.stickers,
icon: const Icon(Icons.emoji_emotions),
onPressed: _sendSticker,
),
Expanded(
child: TextField(
controller: _commentController,

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/utils/file_selector.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:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
@@ -141,8 +142,10 @@ class NewGroupController extends State<NewGroup> {
: sdk.CreateRoomPreset.privateChat,
groupName: nameController.text.isNotEmpty ? nameController.text : null,
powerLevelContentOverride: {
// Only admins (power level 100) are allowed to post in a canal.
'events_default': 100,
// All members may send messages so they can react and comment in the
// publication threads. The app hides the publication composer for
// non-admins in the main canal feed instead.
'events_default': 0,
},
initialState: [
if (avatar != null)
@@ -155,6 +158,7 @@ class NewGroupController extends State<NewGroup> {
final canal = client.getRoomById(roomId);
if (canal != null) {
await canal.addTag(AppConfig.canalTag);
await canal.setRoomType('canal');
}
await _addToSpace(roomId);
if (!mounted) return;
@@ -190,6 +194,7 @@ class NewGroupController extends State<NewGroup> {
final mural = client.getRoomById(roomId);
if (mural != null) {
await mural.addTag(AppConfig.muralTag);
await mural.setRoomType('mural');
}
await _addToSpace(roomId);
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/init_with_restore.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:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@@ -130,6 +131,9 @@ abstract class ClientManager {
'im.ponies.room_emotes',
// To display call state in chat list
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

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

View File

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