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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user