feat: cartões de publicação em canais e corrreções de salas arquivadas
- Canais: publicações viram cartões de largura total mantendo todas as funções do balão (reações, resposta, editar, excluir, comentários em thread). Composer de publicação fica restrito a admins no feed; membros comentam via tela de thread. Canais novos usam events_default 0 para permitir comentários e reações. - Ao sair/excluir uma sala, a cópia local é removida do banco imediatamente; salas já deixadas no servidor não apresentam mais erro ao excluir de novo. - Classificação mural/canal carregada direto do sync (room_type em importantStateEvents + postLoad) sem precisar abrir a sala. - Diálogos com raio e padding reduzidos (dialogTheme, text input e ok/cancel). - Ícones do app regenerados (logo_background) e dependência linkify.
This commit is contained in:
@@ -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,
|
||||
|
||||
5838
lib/l10n/intl_en.arb
5838
lib/l10n/intl_en.arb
File diff suppressed because it is too large
Load Diff
4085
lib/l10n/intl_pt.arb
4085
lib/l10n/intl_pt.arb
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
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
33
lib/utils/matrix_sdk_extensions/room_extension.dart
Normal file
33
lib/utils/matrix_sdk_extensions/room_extension.dart
Normal 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});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user