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.
|
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 814 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 199 KiB |
@@ -56,6 +56,17 @@ abstract class FluffyThemes {
|
|||||||
brightness: brightness,
|
brightness: brightness,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
dividerColor: dividerColor,
|
dividerColor: dividerColor,
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
// Compact dialogs: less padding and a smaller corner radius than the
|
||||||
|
// Material 3 default (28px), so modals like "set chat description"
|
||||||
|
// leave more room for the text field.
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||||
|
),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||||
|
),
|
||||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||||
style: SegmentedButton.styleFrom(
|
style: SegmentedButton.styleFrom(
|
||||||
iconColor: colorScheme.onSurface,
|
iconColor: colorScheme.onSurface,
|
||||||
|
|||||||
@@ -2814,19 +2814,25 @@
|
|||||||
"unexpectedErrorOccurded": "Unfortunately, an unexpected error has occurred: {msg}",
|
"unexpectedErrorOccurded": "Unfortunately, an unexpected error has occurred: {msg}",
|
||||||
"@unexpectedErrorOccurded": {
|
"@unexpectedErrorOccurded": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"msg": {"type": "String"}
|
"msg": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"incomingCallFrom": "Incoming call from {user}",
|
"incomingCallFrom": "Incoming call from {user}",
|
||||||
"@incomingCallFrom": {
|
"@incomingCallFrom": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"user": {"type": "String"}
|
"user": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serverRespondedWithError": "The server has replied with an error message: {error}",
|
"serverRespondedWithError": "The server has replied with an error message: {error}",
|
||||||
"@serverRespondedWithError": {
|
"@serverRespondedWithError": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"error": {"type": "String"}
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
@@ -2913,5 +2919,9 @@
|
|||||||
"@captionHint": {
|
"@captionHint": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
|
},
|
||||||
|
"areYouSureDeletePost": "Delete",
|
||||||
|
"@areYouSureDeletePost": {
|
||||||
|
"description": "Delete this post from the mural"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2042,5 +2042,6 @@
|
|||||||
"@captionHint": {
|
"@captionHint": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
}
|
},
|
||||||
|
"areYouSureDeletePost": "Eliminar"
|
||||||
}
|
}
|
||||||
@@ -2565,5 +2565,6 @@
|
|||||||
"@captionHint": {
|
"@captionHint": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
}
|
},
|
||||||
|
"areYouSureDeletePost": "Apagar"
|
||||||
}
|
}
|
||||||
@@ -2245,5 +2245,6 @@
|
|||||||
"@captionHint": {
|
"@captionHint": {
|
||||||
"type": "String",
|
"type": "String",
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
}
|
},
|
||||||
|
"areYouSureDeletePost": "Eliminar"
|
||||||
}
|
}
|
||||||
@@ -31,13 +31,32 @@ class ArchiveController extends State<Archive> {
|
|||||||
context: context,
|
context: context,
|
||||||
future: () async {
|
future: () async {
|
||||||
Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}');
|
Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}');
|
||||||
await archive[i].room.forget();
|
await forgetRoom(archive[i].room);
|
||||||
archive.removeAt(i);
|
archive.removeAt(i);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forgets [room] and treats a room that is already gone on the server as
|
||||||
|
/// success, so a leftover local copy can always be removed without errors.
|
||||||
|
Future<void> forgetRoom(Room room) async {
|
||||||
|
try {
|
||||||
|
await room.forget();
|
||||||
|
} on MatrixException catch (e, s) {
|
||||||
|
if (!{
|
||||||
|
MatrixError.M_FORBIDDEN,
|
||||||
|
MatrixError.M_NOT_FOUND,
|
||||||
|
MatrixError.M_UNKNOWN,
|
||||||
|
}.contains(e.error)) {
|
||||||
|
Logs().w('Failed to forget room ${room.id}', e, s);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s);
|
||||||
|
room.client.rooms.remove(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> forgetAllAction() async {
|
Future<void> forgetAllAction() async {
|
||||||
final archive = this.archive;
|
final archive = this.archive;
|
||||||
if (archive.isEmpty) return;
|
if (archive.isEmpty) return;
|
||||||
@@ -62,7 +81,7 @@ class ArchiveController extends State<Archive> {
|
|||||||
Logs().v(
|
Logs().v(
|
||||||
'Forget room ${archive.last.room.getLocalizedDisplayname()}',
|
'Forget room ${archive.last.room.getLocalizedDisplayname()}',
|
||||||
);
|
);
|
||||||
await archive.last.room.forget();
|
await forgetRoom(archive.last.room);
|
||||||
archive.removeLast();
|
archive.removeLast();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:collection/collection.dart';
|
|||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:tatchat/config/app_config.dart';
|
|
||||||
import 'package:tatchat/config/setting_keys.dart';
|
import 'package:tatchat/config/setting_keys.dart';
|
||||||
import 'package:tatchat/config/themes.dart';
|
import 'package:tatchat/config/themes.dart';
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
@@ -28,6 +27,7 @@ import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
|||||||
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/other_party_can_receive.dart';
|
import 'package:tatchat/utils/other_party_can_receive.dart';
|
||||||
import 'package:tatchat/utils/platform_infos.dart';
|
import 'package:tatchat/utils/platform_infos.dart';
|
||||||
import 'package:tatchat/utils/show_scaffold_dialog.dart';
|
import 'package:tatchat/utils/show_scaffold_dialog.dart';
|
||||||
@@ -85,7 +85,7 @@ class ChatPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.tags.keys.contains(AppConfig.muralTag)) {
|
if (room.isMural) {
|
||||||
return MuralChatPage(key: Key('mural_page_$roomId'), roomId: roomId);
|
return MuralChatPage(key: Key('mural_page_$roomId'), roomId: roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
|||||||
|
|
||||||
import '../../config/app_config.dart';
|
import '../../config/app_config.dart';
|
||||||
import '../../utils/date_time_extension.dart';
|
import '../../utils/date_time_extension.dart';
|
||||||
|
import '../../utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
|
|
||||||
class ChatEventList extends StatelessWidget {
|
class ChatEventList extends StatelessWidget {
|
||||||
final ChatController controller;
|
final ChatController controller;
|
||||||
@@ -237,6 +238,9 @@ class ChatEventList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
fullWidthCard:
|
||||||
|
controller.activeThreadId == null &&
|
||||||
|
controller.room.isCanal,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import 'package:tatchat/widgets/pulsating_widget.dart';
|
|||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import '../../utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import '../../utils/stream_extension.dart';
|
import '../../utils/stream_extension.dart';
|
||||||
import 'chat_emoji_picker.dart';
|
import 'chat_emoji_picker.dart';
|
||||||
import 'chat_input_row.dart';
|
import 'chat_input_row.dart';
|
||||||
@@ -403,7 +404,13 @@ class ChatView extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: controller.room.canSendDefaultMessages &&
|
: controller.room.canSendDefaultMessages &&
|
||||||
controller.room.membership ==
|
controller.room.membership ==
|
||||||
Membership.join
|
Membership.join &&
|
||||||
|
(controller.room.isCanal &&
|
||||||
|
controller.activeThreadId ==
|
||||||
|
null
|
||||||
|
? controller.room.ownPowerLevel >=
|
||||||
|
PowerLevel.admin
|
||||||
|
: true)
|
||||||
? Material(
|
? Material(
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
color:
|
color:
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class Message extends StatelessWidget {
|
|||||||
final bool isCollapsed;
|
final bool isCollapsed;
|
||||||
final Set<String> bigEmojis;
|
final Set<String> bigEmojis;
|
||||||
|
|
||||||
|
/// Renders the message as a full-width publication card (used in canal
|
||||||
|
/// rooms). Keeps every message feature (reply, reactions, edit, delete,
|
||||||
|
/// thread…), but drops the left/right bubble layout and stretches the
|
||||||
|
/// "bubble" over the whole available width.
|
||||||
|
final bool fullWidthCard;
|
||||||
|
|
||||||
const Message(
|
const Message(
|
||||||
this.event, {
|
this.event, {
|
||||||
this.nextEvent,
|
this.nextEvent,
|
||||||
@@ -79,6 +85,7 @@ class Message extends StatelessWidget {
|
|||||||
this.onExpand,
|
this.onExpand,
|
||||||
required this.enterThread,
|
required this.enterThread,
|
||||||
this.isCollapsed = false,
|
this.isCollapsed = false,
|
||||||
|
this.fullWidthCard = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +112,12 @@ class Message extends StatelessWidget {
|
|||||||
|
|
||||||
final client = Matrix.of(context).client;
|
final client = Matrix.of(context).client;
|
||||||
final ownMessage = event.senderId == client.userID;
|
final ownMessage = event.senderId == client.userID;
|
||||||
final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft;
|
final card = fullWidthCard;
|
||||||
|
final alignment = card
|
||||||
|
? Alignment.topLeft
|
||||||
|
: ownMessage
|
||||||
|
? Alignment.topRight
|
||||||
|
: Alignment.topLeft;
|
||||||
|
|
||||||
var color = theme.colorScheme.surfaceContainerHigh;
|
var color = theme.colorScheme.surfaceContainerHigh;
|
||||||
final displayTime =
|
final displayTime =
|
||||||
@@ -128,6 +140,10 @@ class Message extends StatelessWidget {
|
|||||||
previousEvent!.senderId == event.senderId &&
|
previousEvent!.senderId == event.senderId &&
|
||||||
previousEvent!.originServerTs.sameEnvironment(event.originServerTs);
|
previousEvent!.originServerTs.sameEnvironment(event.originServerTs);
|
||||||
|
|
||||||
|
// In card mode the sender name moves inside the card, so show it even for
|
||||||
|
// own messages (otherwise a publication feed would be anonymous).
|
||||||
|
final showSenderName = !nextEventSameSender && (card || !ownMessage);
|
||||||
|
|
||||||
final textColor = ownMessage
|
final textColor = ownMessage
|
||||||
? theme.onBubbleColor
|
? theme.onBubbleColor
|
||||||
: theme.colorScheme.onSurface;
|
: theme.colorScheme.onSurface;
|
||||||
@@ -136,16 +152,22 @@ class Message extends StatelessWidget {
|
|||||||
? theme.colorScheme.onTertiaryContainer
|
? theme.colorScheme.onTertiaryContainer
|
||||||
: theme.colorScheme.tertiary;
|
: theme.colorScheme.tertiary;
|
||||||
|
|
||||||
final rowMainAxisAlignment = ownMessage
|
final rowMainAxisAlignment = card
|
||||||
|
? MainAxisAlignment.start
|
||||||
|
: ownMessage
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
: MainAxisAlignment.start;
|
: MainAxisAlignment.start;
|
||||||
|
|
||||||
final displayEvent = event.getDisplayEvent(timeline);
|
final displayEvent = event.getDisplayEvent(timeline);
|
||||||
const hardCorner = Radius.circular(3);
|
const hardCorner = Radius.circular(3);
|
||||||
const roundedCorner = Radius.circular(AppConfig.borderRadius);
|
const roundedCorner = Radius.circular(AppConfig.borderRadius);
|
||||||
final borderRadius = BorderRadius.only(
|
final borderRadius = card
|
||||||
|
? BorderRadius.all(roundedCorner)
|
||||||
|
: BorderRadius.only(
|
||||||
topLeft: !ownMessage ? hardCorner : roundedCorner,
|
topLeft: !ownMessage ? hardCorner : roundedCorner,
|
||||||
topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner,
|
topRight: ownMessage && nextEventSameSender
|
||||||
|
? hardCorner
|
||||||
|
: roundedCorner,
|
||||||
bottomLeft: !ownMessage && previousEventSameSender
|
bottomLeft: !ownMessage && previousEventSameSender
|
||||||
? hardCorner
|
? hardCorner
|
||||||
: roundedCorner,
|
: roundedCorner,
|
||||||
@@ -229,9 +251,9 @@ class Message extends StatelessWidget {
|
|||||||
: SwipeDirection.startToEnd,
|
: SwipeDirection.startToEnd,
|
||||||
onSwipe: (_) => onSwipe(),
|
onSwipe: (_) => onSwipe(),
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(
|
constraints: card
|
||||||
maxWidth: FluffyThemes.maxTimelineWidth,
|
? const BoxConstraints()
|
||||||
),
|
: const BoxConstraints(maxWidth: FluffyThemes.maxTimelineWidth),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 8.0,
|
left: 8.0,
|
||||||
right: 8.0,
|
right: 8.0,
|
||||||
@@ -242,7 +264,11 @@ class Message extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
crossAxisAlignment: ownMessage ? .end : .start,
|
crossAxisAlignment: card
|
||||||
|
? CrossAxisAlignment.stretch
|
||||||
|
: ownMessage
|
||||||
|
? .end
|
||||||
|
: .start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
@@ -275,7 +301,9 @@ class Message extends StatelessWidget {
|
|||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
mainAxisAlignment: rowMainAxisAlignment,
|
mainAxisAlignment: rowMainAxisAlignment,
|
||||||
children: [
|
children: [
|
||||||
if (longPressSelect && !event.redacted)
|
if (card)
|
||||||
|
const SizedBox.shrink()
|
||||||
|
else if (longPressSelect && !event.redacted)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: avatarSize,
|
height: avatarSize,
|
||||||
width: avatarSize,
|
width: avatarSize,
|
||||||
@@ -318,10 +346,15 @@ class Message extends StatelessWidget {
|
|||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
|
if (!card)
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 8.0),
|
padding: EdgeInsets.only(left: 8.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: ownMessage ? .end : .start,
|
mainAxisAlignment: card
|
||||||
|
? .start
|
||||||
|
: ownMessage
|
||||||
|
? .end
|
||||||
|
: .start,
|
||||||
children: [
|
children: [
|
||||||
if (sender.powerLevel.role !=
|
if (sender.powerLevel.role !=
|
||||||
PowerLevelRole.user &&
|
PowerLevelRole.user &&
|
||||||
@@ -348,7 +381,8 @@ class Message extends StatelessWidget {
|
|||||||
future: event.fetchSenderUser(),
|
future: event.fetchSenderUser(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final displayname =
|
final displayname =
|
||||||
snapshot.data?.calcDisplayname() ??
|
snapshot.data
|
||||||
|
?.calcDisplayname() ??
|
||||||
sender.calcDisplayname();
|
sender.calcDisplayname();
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@@ -438,6 +472,7 @@ class Message extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
animateIn: animateIn,
|
animateIn: animateIn,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
width: card ? double.infinity : null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: noBubble
|
color: noBubble
|
||||||
? Colors.transparent
|
? Colors.transparent
|
||||||
@@ -458,15 +493,108 @@ class Message extends StatelessWidget {
|
|||||||
AppConfig.borderRadius,
|
AppConfig.borderRadius,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
constraints: const BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth:
|
maxWidth: card
|
||||||
FluffyThemes.columnWidth * 1.5,
|
? double.infinity
|
||||||
|
: FluffyThemes.columnWidth * 1.5,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
crossAxisAlignment:
|
crossAxisAlignment:
|
||||||
CrossAxisAlignment.start,
|
CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
if (card && showSenderName)
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (sender
|
||||||
|
.powerLevel
|
||||||
|
.role !=
|
||||||
|
PowerLevelRole.user)
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.only(
|
||||||
|
right: 2.0,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
sender
|
||||||
|
.powerLevel
|
||||||
|
.role ==
|
||||||
|
PowerLevelRole
|
||||||
|
.moderator
|
||||||
|
? Icons
|
||||||
|
.add_moderator_outlined
|
||||||
|
: Icons
|
||||||
|
.admin_panel_settings,
|
||||||
|
size: 14,
|
||||||
|
color: theme
|
||||||
|
.colorScheme
|
||||||
|
.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<User?>(
|
||||||
|
future: event
|
||||||
|
.fetchSenderUser(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final displayname =
|
||||||
|
snapshot.data
|
||||||
|
?.calcDisplayname() ??
|
||||||
|
sender
|
||||||
|
.calcDisplayname();
|
||||||
|
return Text(
|
||||||
|
displayname,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight:
|
||||||
|
FontWeight
|
||||||
|
.bold,
|
||||||
|
color:
|
||||||
|
theme.brightness ==
|
||||||
|
Brightness
|
||||||
|
.light
|
||||||
|
? displayname
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
: displayname
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow:
|
||||||
|
TextOverflow
|
||||||
|
.ellipsis,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (displayTime &&
|
||||||
|
event
|
||||||
|
.status
|
||||||
|
.isSent) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
event.originServerTs
|
||||||
|
.localizedTimeOfDay(
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
eventStateTextColor,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
if (event.inReplyToEventId(
|
if (event.inReplyToEventId(
|
||||||
includingFallback: false,
|
includingFallback: false,
|
||||||
) !=
|
) !=
|
||||||
@@ -557,7 +685,9 @@ class Message extends StatelessWidget {
|
|||||||
child: !hasReactions
|
child: !hasReactions
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Container(
|
: Container(
|
||||||
alignment: ownMessage
|
alignment: card
|
||||||
|
? Alignment.centerLeft
|
||||||
|
: ownMessage
|
||||||
? Alignment.centerRight
|
? Alignment.centerRight
|
||||||
: Alignment.centerLeft,
|
: Alignment.centerLeft,
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
@@ -569,7 +699,11 @@ class Message extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: ownMessage ? .end : .start,
|
mainAxisAlignment: card
|
||||||
|
? .start
|
||||||
|
: ownMessage
|
||||||
|
? .end
|
||||||
|
: .start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (event.status.isSent &&
|
if (event.status.isSent &&
|
||||||
@@ -643,7 +777,9 @@ class Message extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Align(
|
Align(
|
||||||
alignment: ownMessage
|
alignment: card
|
||||||
|
? Alignment.bottomLeft
|
||||||
|
: ownMessage
|
||||||
? Alignment.bottomRight
|
? Alignment.bottomRight
|
||||||
: Alignment.bottomLeft,
|
: Alignment.bottomLeft,
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
@@ -861,19 +997,24 @@ class Message extends StatelessWidget {
|
|||||||
child: threadChildren.isEmpty
|
child: threadChildren.isEmpty
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: Padding(
|
: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(top: 2.0, bottom: 8.0)
|
||||||
top: 2.0,
|
.add(
|
||||||
bottom: 8.0,
|
EdgeInsets.only(
|
||||||
left: avatarSize + 8,
|
left: card ? 8.0 : avatarSize + 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: FluffyThemes.columnWidth * 1.5,
|
maxWidth: card
|
||||||
|
? double.infinity
|
||||||
|
: FluffyThemes.columnWidth * 1.5,
|
||||||
),
|
),
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppConfig.borderRadius,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
foregroundColor:
|
foregroundColor:
|
||||||
theme.colorScheme.onSecondaryContainer,
|
theme.colorScheme.onSecondaryContainer,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
|||||||
import 'package:tatchat/utils/error_reporter.dart';
|
import 'package:tatchat/utils/error_reporter.dart';
|
||||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/platform_infos.dart';
|
import 'package:tatchat/utils/platform_infos.dart';
|
||||||
import 'package:tatchat/utils/show_scaffold_dialog.dart';
|
import 'package:tatchat/utils/show_scaffold_dialog.dart';
|
||||||
import 'package:tatchat/utils/show_update_snackbar.dart';
|
import 'package:tatchat/utils/show_update_snackbar.dart';
|
||||||
@@ -402,6 +403,7 @@ class ChatListController extends State<ChatList>
|
|||||||
}
|
}
|
||||||
|
|
||||||
StreamSubscription? _onRoomTagUpdate;
|
StreamSubscription? _onRoomTagUpdate;
|
||||||
|
StreamSubscription? _backfillSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -411,6 +413,8 @@ class ChatListController extends State<ChatList>
|
|||||||
|
|
||||||
scrollController.addListener(_onScroll);
|
scrollController.addListener(_onScroll);
|
||||||
_waitForFirstSync();
|
_waitForFirstSync();
|
||||||
|
_backfillSubscription =
|
||||||
|
_clientStream.stream.listen((_) => backfillRoomTypeMarkers());
|
||||||
if (PlatformInfos.isMobile) {
|
if (PlatformInfos.isMobile) {
|
||||||
_callEventSubscription = FlutterCallkitIncoming.onEvent.listen(
|
_callEventSubscription = FlutterCallkitIncoming.onEvent.listen(
|
||||||
_onCallEvent,
|
_onCallEvent,
|
||||||
@@ -474,6 +478,7 @@ class ChatListController extends State<ChatList>
|
|||||||
_intentFileStreamSubscription?.cancel();
|
_intentFileStreamSubscription?.cancel();
|
||||||
_callEventSubscription?.cancel();
|
_callEventSubscription?.cancel();
|
||||||
_onRoomTagUpdate?.cancel();
|
_onRoomTagUpdate?.cancel();
|
||||||
|
_backfillSubscription?.cancel();
|
||||||
scrollController.removeListener(_onScroll);
|
scrollController.removeListener(_onScroll);
|
||||||
searchController.dispose();
|
searchController.dispose();
|
||||||
searchFocusNode.dispose();
|
searchFocusNode.dispose();
|
||||||
@@ -775,7 +780,10 @@ class ChatListController extends State<ChatList>
|
|||||||
if (confirmed == OkCancelResult.cancel) return;
|
if (confirmed == OkCancelResult.cancel) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
await showFutureLoadingDialog(context: context, future: room.leave);
|
await showFutureLoadingDialog(
|
||||||
|
context: context,
|
||||||
|
future: () => leaveRoom(room),
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
case ChatContextAction.addToSpace:
|
case ChatContextAction.addToSpace:
|
||||||
@@ -901,6 +909,65 @@ class ChatListController extends State<ChatList>
|
|||||||
|
|
||||||
bool waitForFirstSync = false;
|
bool waitForFirstSync = false;
|
||||||
|
|
||||||
|
/// Leaves the room locally and removes it from the local database.
|
||||||
|
Future<void> leaveRoom(Room room) async {
|
||||||
|
try {
|
||||||
|
await room.leave();
|
||||||
|
} on MatrixException catch (e, s) {
|
||||||
|
// If the room was already left on the server (e.g. a stale local copy
|
||||||
|
// reappeared after a restart) the leave request fails, but the user
|
||||||
|
// intent (room not being listed anymore) is already fulfilled. Clean up
|
||||||
|
// locally instead of showing an error.
|
||||||
|
if (!{
|
||||||
|
MatrixError.M_FORBIDDEN,
|
||||||
|
MatrixError.M_NOT_FOUND,
|
||||||
|
MatrixError.M_UNKNOWN,
|
||||||
|
}.contains(e.error)) {
|
||||||
|
Logs().w('Failed to leave room ${room.id}', e, s);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
Logs().w('Room ${room.id} is already gone, cleaning up locally...', e, s);
|
||||||
|
}
|
||||||
|
final client = room.client;
|
||||||
|
// Make sure the room does not reappear from the local database after an
|
||||||
|
// app restart, even if the leave sync did not run in time.
|
||||||
|
await client.database.forgetRoom(room.id);
|
||||||
|
client.rooms.remove(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the shared [roomTypeStateEvent] marker from the synced room state
|
||||||
|
/// (from the local database for partially loaded rooms) and writes it for
|
||||||
|
/// existing canal/mural rooms that only have the per-account tag of the
|
||||||
|
/// creating account. This makes the category visible right after the sync,
|
||||||
|
/// without having to open the room first. Only admins can write the marker,
|
||||||
|
/// non-admin members simply read it.
|
||||||
|
Future<void> backfillRoomTypeMarkers() async {
|
||||||
|
final client = Matrix.of(context).client;
|
||||||
|
if (client.rooms.isEmpty) return;
|
||||||
|
for (final room in client.rooms) {
|
||||||
|
if (room.membership != Membership.join) continue;
|
||||||
|
if (room.roomType != null) continue;
|
||||||
|
// Partial rooms do not keep unimportant state events (like our room type
|
||||||
|
// marker) in memory, so load them from the local database first.
|
||||||
|
await room.postLoad();
|
||||||
|
final type =
|
||||||
|
room.roomType ??
|
||||||
|
(room.tags.keys.contains(AppConfig.canalTag)
|
||||||
|
? 'canal'
|
||||||
|
: room.tags.keys.contains(AppConfig.muralTag)
|
||||||
|
? 'mural'
|
||||||
|
: null);
|
||||||
|
if (type == null || room.roomType == type) continue;
|
||||||
|
if (room.ownPowerLevel < PowerLevel.admin) continue;
|
||||||
|
try {
|
||||||
|
await room.setRoomType(type);
|
||||||
|
} catch (e, s) {
|
||||||
|
Logs().w('Failed to backfill room type $type for ${room.id}', e, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _waitForFirstSync() async {
|
Future<void> _waitForFirstSync() async {
|
||||||
final router = GoRouter.of(context);
|
final router = GoRouter.of(context);
|
||||||
final client = Matrix.of(context).client;
|
final client = Matrix.of(context).client;
|
||||||
@@ -921,6 +988,7 @@ class ChatListController extends State<ChatList>
|
|||||||
setState(() {
|
setState(() {
|
||||||
waitForFirstSync = true;
|
waitForFirstSync = true;
|
||||||
});
|
});
|
||||||
|
backfillRoomTypeMarkers();
|
||||||
|
|
||||||
if (client.userDeviceKeys[client.userID!]?.deviceKeys.values.any(
|
if (client.userDeviceKeys[client.userID!]?.deviceKeys.values.any(
|
||||||
(device) => !device.verified && !device.blocked,
|
(device) => !device.verified && !device.blocked,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||||
import 'package:tatchat/config/app_config.dart';
|
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||||
import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart';
|
import 'package:tatchat/pages/chat_list/dummy_chat_list_item.dart';
|
||||||
import 'package:tatchat/pages/chat_list/search_title.dart';
|
import 'package:tatchat/pages/chat_list/search_title.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/stream_extension.dart';
|
import 'package:tatchat/utils/stream_extension.dart';
|
||||||
import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart';
|
import 'package:tatchat/widgets/adaptive_dialogs/public_room_dialog.dart';
|
||||||
import 'package:tatchat/widgets/avatar.dart';
|
import 'package:tatchat/widgets/avatar.dart';
|
||||||
@@ -59,8 +59,9 @@ class ChatListViewBody extends StatelessWidget {
|
|||||||
.where(
|
.where(
|
||||||
(room) =>
|
(room) =>
|
||||||
!room.isSpace &&
|
!room.isSpace &&
|
||||||
!room.tags.keys.contains(AppConfig.canalTag) &&
|
!room.isLeftOrBanned &&
|
||||||
!room.tags.keys.contains(AppConfig.muralTag) &&
|
!room.isCanal &&
|
||||||
|
!room.isMural &&
|
||||||
spaceDelegateCandidates[room.id] == null,
|
spaceDelegateCandidates[room.id] == null,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class ChatListItem extends StatelessWidget {
|
|||||||
final void Function()? onForget;
|
final void Function()? onForget;
|
||||||
final void Function() onTap;
|
final void Function() onTap;
|
||||||
final String? filter;
|
final String? filter;
|
||||||
|
final Uri? displayAvatar;
|
||||||
|
final String? displayAvatarName;
|
||||||
|
|
||||||
const ChatListItem(
|
const ChatListItem(
|
||||||
this.room, {
|
this.room, {
|
||||||
@@ -38,6 +40,8 @@ class ChatListItem extends StatelessWidget {
|
|||||||
this.onForget,
|
this.onForget,
|
||||||
this.filter,
|
this.filter,
|
||||||
this.space,
|
this.space,
|
||||||
|
this.displayAvatar,
|
||||||
|
this.displayAvatarName,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,11 +152,11 @@ class ChatListItem extends StatelessWidget {
|
|||||||
AppConfig.spaceBorderRadius,
|
AppConfig.spaceBorderRadius,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
mxContent: room.avatar,
|
mxContent: displayAvatar ?? room.avatar,
|
||||||
size: space != null
|
size: space != null
|
||||||
? Avatar.defaultSize * 0.75
|
? Avatar.defaultSize * 0.75
|
||||||
: Avatar.defaultSize,
|
: Avatar.defaultSize,
|
||||||
name: displayname,
|
name: displayAvatarName ?? displayname,
|
||||||
presenceUserId: directChatMatrixId,
|
presenceUserId: directChatMatrixId,
|
||||||
presenceBackgroundColor: backgroundColor,
|
presenceBackgroundColor: backgroundColor,
|
||||||
onTap: () => onLongPress?.call(context),
|
onTap: () => onLongPress?.call(context),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:tatchat/config/app_config.dart';
|
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||||
@@ -13,6 +12,7 @@ import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart';
|
|||||||
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
||||||
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/stream_extension.dart';
|
import 'package:tatchat/utils/stream_extension.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
|
|
||||||
@@ -63,7 +63,8 @@ class _ChannelsTabState extends State<ChannelsTab> {
|
|||||||
.rateLimit(const Duration(seconds: 1)),
|
.rateLimit(const Duration(seconds: 1)),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final channels = client.rooms
|
final channels = client.rooms
|
||||||
.where((room) => room.tags.keys.contains(AppConfig.canalTag))
|
.where((room) => !room.isLeftOrBanned)
|
||||||
|
.where((room) => room.isCanal)
|
||||||
.where(
|
.where(
|
||||||
(room) =>
|
(room) =>
|
||||||
searchText.isEmpty ||
|
searchText.isEmpty ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:tatchat/config/app_config.dart';
|
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||||
@@ -13,6 +12,7 @@ import 'package:tatchat/pages/chat_list/tabs/chat_tab_header.dart';
|
|||||||
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
import 'package:tatchat/pages/chat_list/tabs/empty_tab_view.dart';
|
||||||
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
import 'package:tatchat/pages/chat_list/tabs/home_tab.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/stream_extension.dart';
|
import 'package:tatchat/utils/stream_extension.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
|
|
||||||
@@ -63,7 +63,8 @@ class _MuralsTabState extends State<MuralsTab> {
|
|||||||
.rateLimit(const Duration(seconds: 1)),
|
.rateLimit(const Duration(seconds: 1)),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final murals = client.rooms
|
final murals = client.rooms
|
||||||
.where((room) => room.tags.keys.contains(AppConfig.muralTag))
|
.where((room) => !room.isLeftOrBanned)
|
||||||
|
.where((room) => room.isMural)
|
||||||
.where(
|
.where(
|
||||||
(room) =>
|
(room) =>
|
||||||
searchText.isEmpty ||
|
searchText.isEmpty ||
|
||||||
@@ -90,11 +91,24 @@ class _MuralsTabState extends State<MuralsTab> {
|
|||||||
itemCount: murals.length,
|
itemCount: murals.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final room = murals[i];
|
final room = murals[i];
|
||||||
|
final creatorId = room.creatorUserIds.isEmpty
|
||||||
|
? null
|
||||||
|
: room.creatorUserIds.first;
|
||||||
|
final creatorState = creatorId == null
|
||||||
|
? null
|
||||||
|
: room.getState(EventTypes.RoomMember, creatorId);
|
||||||
return ChatListItem(
|
return ChatListItem(
|
||||||
room,
|
room,
|
||||||
key: Key('mural_list_item_${room.id}'),
|
key: Key('mural_list_item_${room.id}'),
|
||||||
onTap: () => widget.controller.onChatTap(room),
|
onTap: () => widget.controller.onChatTap(room),
|
||||||
activeChat: widget.controller.activeChat == room.id,
|
activeChat: widget.controller.activeChat == room.id,
|
||||||
|
displayAvatar: Uri.tryParse(
|
||||||
|
creatorState?.content.tryGet<String>('avatar_url') ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
displayAvatarName: creatorState?.content.tryGet<String>(
|
||||||
|
'displayname',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,20 +3,27 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||||
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:linkify/linkify.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||||
|
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||||
import 'package:tatchat/pages/image_viewer/video_player.dart';
|
import 'package:tatchat/pages/image_viewer/video_player.dart';
|
||||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||||
import 'package:tatchat/utils/stream_extension.dart';
|
import 'package:tatchat/utils/stream_extension.dart';
|
||||||
|
import 'package:tatchat/utils/url_launcher.dart';
|
||||||
|
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||||
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
|
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
|
||||||
|
import 'package:tatchat/widgets/adaptive_dialogs/user_dialog.dart';
|
||||||
import 'package:tatchat/widgets/avatar.dart';
|
import 'package:tatchat/widgets/avatar.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
import 'package:tatchat/widgets/mxc_image.dart';
|
import 'package:tatchat/widgets/mxc_image.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
|
||||||
import 'package:matrix/matrix.dart';
|
|
||||||
|
|
||||||
const _muralMediaTypes = {
|
const _muralMediaTypes = {
|
||||||
MessageTypes.Image,
|
MessageTypes.Image,
|
||||||
@@ -68,7 +75,10 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
? const []
|
? const []
|
||||||
: timeline!.events
|
: timeline!.events
|
||||||
.where(
|
.where(
|
||||||
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType),
|
(e) =>
|
||||||
|
!e.redacted &&
|
||||||
|
e.relationshipType == null &&
|
||||||
|
_muralMediaTypes.contains(e.messageType),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -340,7 +350,6 @@ class _Header extends StatelessWidget {
|
|||||||
final creatorAvatar = Uri.tryParse(
|
final creatorAvatar = Uri.tryParse(
|
||||||
creatorState?.content.tryGet<String>('avatar_url') ?? '',
|
creatorState?.content.tryGet<String>('avatar_url') ?? '',
|
||||||
);
|
);
|
||||||
final topic = room.topic.trim();
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -406,10 +415,6 @@ class _Header extends StatelessWidget {
|
|||||||
left: 16 + avatarSize + 12,
|
left: 16 + avatarSize + 12,
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: -avatarSize / 2,
|
bottom: -avatarSize / 2,
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -430,27 +435,32 @@ class _Header extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 8, 0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
DecoratedBox(
|
DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.35),
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
tooltip: l10n.settings,
|
tooltip: l10n.settings,
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
color: Colors.white,
|
|
||||||
onPressed: onEditInfo,
|
onPressed: onEditInfo,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||||
child: _ExpandableDescription(
|
child: _MuralDescription(
|
||||||
text: topic,
|
room: room,
|
||||||
|
text: room.topic,
|
||||||
expanded: descriptionExpanded,
|
expanded: descriptionExpanded,
|
||||||
onToggle: onToggleDescription,
|
onToggle: onToggleDescription,
|
||||||
),
|
),
|
||||||
@@ -460,12 +470,14 @@ class _Header extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExpandableDescription extends StatelessWidget {
|
class _MuralDescription extends StatelessWidget {
|
||||||
|
final Room room;
|
||||||
final String? text;
|
final String? text;
|
||||||
final bool expanded;
|
final bool expanded;
|
||||||
final VoidCallback onToggle;
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
const _ExpandableDescription({
|
const _MuralDescription({
|
||||||
|
required this.room,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.expanded,
|
required this.expanded,
|
||||||
required this.onToggle,
|
required this.onToggle,
|
||||||
@@ -478,10 +490,54 @@ class _ExpandableDescription extends StatelessWidget {
|
|||||||
final text = this.text?.trim();
|
final text = this.text?.trim();
|
||||||
if (text == null || text.isEmpty) return const SizedBox.shrink();
|
if (text == null || text.isEmpty) return const SizedBox.shrink();
|
||||||
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4);
|
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4);
|
||||||
|
final linkStyle = TextStyle(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
decorationColor: theme.colorScheme.primary,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
);
|
||||||
|
const linkifiers = [UrlLinkifier(), EmailLinkifier(), UserTagLinkifier()];
|
||||||
|
final elements = linkify(text, linkifiers: linkifiers);
|
||||||
|
|
||||||
|
Future<void> openLink(LinkableElement rawLink) async {
|
||||||
|
final url = rawLink.url;
|
||||||
|
if (rawLink is UserTagElement) {
|
||||||
|
final userTag = rawLink.userTag;
|
||||||
|
var mentionId = userTag.startsWith('@')
|
||||||
|
? userTag.substring(1)
|
||||||
|
: userTag;
|
||||||
|
if (!mentionId.contains(':')) {
|
||||||
|
mentionId = '$mentionId:${room.client.homeserver?.host ?? ''}';
|
||||||
|
}
|
||||||
|
final memberState = room.getState(EventTypes.RoomMember, mentionId);
|
||||||
|
final profile = Profile(
|
||||||
|
userId: mentionId,
|
||||||
|
displayName:
|
||||||
|
memberState?.content.tryGet<String>('displayname') ?? userTag,
|
||||||
|
avatarUrl: Uri.tryParse(
|
||||||
|
memberState?.content.tryGet<String>('avatar_url') ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await UserDialog.show(context: context, profile: profile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UrlLauncher(context, url).launchUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget richText({bool measure = false}) => Text.rich(
|
||||||
|
buildTextSpan(
|
||||||
|
elements,
|
||||||
|
style: style,
|
||||||
|
linkStyle: linkStyle,
|
||||||
|
onOpen: openLink,
|
||||||
|
),
|
||||||
|
maxLines: expanded ? null : 4,
|
||||||
|
overflow: expanded ? null : TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final painter = TextPainter(
|
final painter = TextPainter(
|
||||||
text: TextSpan(text: text, style: style),
|
text: buildTextSpan(elements, style: style, linkStyle: linkStyle),
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
)..layout(maxWidth: constraints.maxWidth);
|
)..layout(maxWidth: constraints.maxWidth);
|
||||||
@@ -489,12 +545,7 @@ class _ExpandableDescription extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
richText(),
|
||||||
text,
|
|
||||||
style: style,
|
|
||||||
maxLines: expanded ? null : 4,
|
|
||||||
overflow: expanded ? null : TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (exceeds)
|
if (exceeds)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: onToggle,
|
onPressed: onToggle,
|
||||||
@@ -671,6 +722,50 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId);
|
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _deletePost() async {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
|
final delete = await showOkCancelAlertDialog(
|
||||||
|
context: context,
|
||||||
|
title: l10n.areYouSure,
|
||||||
|
message: l10n.areYouSureDeletePost,
|
||||||
|
okLabel: l10n.delete,
|
||||||
|
cancelLabel: l10n.cancel,
|
||||||
|
);
|
||||||
|
if (delete != OkCancelResult.ok || !mounted) return;
|
||||||
|
try {
|
||||||
|
await event.redactEvent();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(L10n.of(context).oopsSomethingWentWrong)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reportPost() async {
|
||||||
|
final l10n = L10n.of(context);
|
||||||
|
final reason = await showTextInputDialog(
|
||||||
|
context: context,
|
||||||
|
title: l10n.whyDoYouWantToReportThis,
|
||||||
|
okLabel: l10n.report,
|
||||||
|
cancelLabel: l10n.cancel,
|
||||||
|
hintText: l10n.reason,
|
||||||
|
);
|
||||||
|
if (reason == null || reason.isEmpty || !mounted) return;
|
||||||
|
await widget.room.client.reportEvent(
|
||||||
|
widget.room.id,
|
||||||
|
event.eventId,
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.contentHasBeenReported)));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendComment() async {
|
Future<void> _sendComment() async {
|
||||||
final text = _commentController.text.trim();
|
final text = _commentController.text.trim();
|
||||||
if (text.isEmpty || _sending) return;
|
if (text.isEmpty || _sending) return;
|
||||||
@@ -683,6 +778,86 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _typeEmoji(Emoji? emoji) {
|
||||||
|
if (emoji == null) return;
|
||||||
|
final emojiText = emoji.emoji;
|
||||||
|
final value = _commentController.value;
|
||||||
|
if (value.selection.start != -1 && value.selection.isValid) {
|
||||||
|
final newText = value.text.replaceRange(
|
||||||
|
value.selection.start,
|
||||||
|
value.selection.end,
|
||||||
|
emojiText,
|
||||||
|
);
|
||||||
|
_commentController.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: value.selection.start + emojiText.characters.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_commentController.text += emojiText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEmojiPicker() async {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
builder: (context) => SizedBox(
|
||||||
|
height: MediaQuery.sizeOf(context).height * 0.5,
|
||||||
|
child: EmojiPicker(
|
||||||
|
onEmojiSelected: (_, emoji) {
|
||||||
|
_typeEmoji(emoji);
|
||||||
|
},
|
||||||
|
config: Config(
|
||||||
|
locale: Localizations.localeOf(context),
|
||||||
|
emojiViewConfig: EmojiViewConfig(
|
||||||
|
backgroundColor: theme.colorScheme.onInverseSurface,
|
||||||
|
),
|
||||||
|
bottomActionBarConfig: const BottomActionBarConfig(enabled: false),
|
||||||
|
categoryViewConfig: CategoryViewConfig(
|
||||||
|
backspaceColor: theme.colorScheme.primary,
|
||||||
|
iconColor: theme.colorScheme.primary.withAlpha(128),
|
||||||
|
iconColorSelected: theme.colorScheme.primary,
|
||||||
|
indicatorColor: theme.colorScheme.primary,
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
skinToneConfig: const SkinToneConfig(indicatorColor: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendSticker() async {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
builder: (context) => SizedBox(
|
||||||
|
height: MediaQuery.sizeOf(context).height * 0.6,
|
||||||
|
child: StickerPickerDialog(
|
||||||
|
room: widget.room,
|
||||||
|
onSelected: (sticker) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
widget.room.sendEvent(
|
||||||
|
{
|
||||||
|
'body': sticker.body,
|
||||||
|
'info': sticker.info ?? {},
|
||||||
|
'url': sticker.url.toString(),
|
||||||
|
},
|
||||||
|
type: EventTypes.Sticker,
|
||||||
|
threadRootEventId: event.eventId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<Event> _comments() {
|
List<Event> _comments() {
|
||||||
final comments = widget.timeline.events
|
final comments = widget.timeline.events
|
||||||
.where(
|
.where(
|
||||||
@@ -696,6 +871,18 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
return comments;
|
return comments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openProfile(Event comment) {
|
||||||
|
final sender = comment.senderFromMemoryOrFallback;
|
||||||
|
UserDialog.show(
|
||||||
|
context: context,
|
||||||
|
profile: Profile(
|
||||||
|
userId: sender.id,
|
||||||
|
displayName: sender.calcDisplayname(),
|
||||||
|
avatarUrl: sender.avatarUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -772,11 +959,13 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.editCaption,
|
tooltip: liked ? l10n.unlike : l10n.like,
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: Icon(
|
||||||
onPressed: _editCaption,
|
liked ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: liked ? Colors.redAccent : null,
|
||||||
|
),
|
||||||
|
onPressed: _toggleLike,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
if (_likeCount > 0)
|
if (_likeCount > 0)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
@@ -787,13 +976,46 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
const Spacer(),
|
||||||
tooltip: liked ? l10n.unlike : l10n.like,
|
PopupMenuButton<String>(
|
||||||
icon: Icon(
|
tooltip: l10n.more,
|
||||||
liked ? Icons.favorite : Icons.favorite_border,
|
onSelected: (action) async {
|
||||||
color: liked ? Colors.redAccent : null,
|
switch (action) {
|
||||||
|
case 'edit':
|
||||||
|
await _editCaption();
|
||||||
|
case 'delete':
|
||||||
|
await _deletePost();
|
||||||
|
case 'report':
|
||||||
|
await _reportPost();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'edit',
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: Text(l10n.editCaption),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
onPressed: _toggleLike,
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline),
|
||||||
|
title: Text(l10n.delete),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'report',
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.flag_outlined),
|
||||||
|
title: Text(l10n.report),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -827,6 +1049,9 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
itemCount: comments.length,
|
itemCount: comments.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final comment = comments[i];
|
final comment = comments[i];
|
||||||
|
final isSticker =
|
||||||
|
comment.messageType == MessageTypes.Sticker;
|
||||||
|
final sender = comment.senderFromMemoryOrFallback;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -835,30 +1060,41 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Avatar(
|
GestureDetector(
|
||||||
mxContent: comment
|
onTap: () => _openProfile(comment),
|
||||||
.senderFromMemoryOrFallback
|
child: Avatar(
|
||||||
.avatarUrl,
|
mxContent: sender.avatarUrl,
|
||||||
name: comment.senderFromMemoryOrFallback
|
name: sender.calcDisplayname(),
|
||||||
.calcDisplayname(),
|
|
||||||
size: 32,
|
size: 32,
|
||||||
client: widget.room.client,
|
client: widget.room.client,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment:
|
||||||
CrossAxisAlignment.start,
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
GestureDetector(
|
||||||
comment.senderFromMemoryOrFallback
|
onTap: () => _openProfile(comment),
|
||||||
.calcDisplayname(),
|
child: Text(
|
||||||
|
sender.calcDisplayname(),
|
||||||
style: theme.textTheme.labelMedium
|
style: theme.textTheme.labelMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
if (isSticker)
|
||||||
|
MxcImage(
|
||||||
|
key: ValueKey(comment.eventId),
|
||||||
|
event: comment,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
isThumbnail: false,
|
||||||
|
width: double.infinity,
|
||||||
|
)
|
||||||
|
else
|
||||||
Text(
|
Text(
|
||||||
comment
|
comment
|
||||||
.getDisplayEvent(widget.timeline)
|
.getDisplayEvent(widget.timeline)
|
||||||
@@ -879,9 +1115,19 @@ class _PostViewerState extends State<_PostViewer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.emojis,
|
||||||
|
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||||
|
onPressed: _showEmojiPicker,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.stickers,
|
||||||
|
icon: const Icon(Icons.emoji_emotions),
|
||||||
|
onPressed: _sendSticker,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _commentController,
|
controller: _commentController,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:tatchat/l10n/l10n.dart';
|
|||||||
import 'package:tatchat/pages/new_group/new_group_view.dart';
|
import 'package:tatchat/pages/new_group/new_group_view.dart';
|
||||||
import 'package:tatchat/utils/file_selector.dart';
|
import 'package:tatchat/utils/file_selector.dart';
|
||||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
|
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
@@ -141,8 +142,10 @@ class NewGroupController extends State<NewGroup> {
|
|||||||
: sdk.CreateRoomPreset.privateChat,
|
: sdk.CreateRoomPreset.privateChat,
|
||||||
groupName: nameController.text.isNotEmpty ? nameController.text : null,
|
groupName: nameController.text.isNotEmpty ? nameController.text : null,
|
||||||
powerLevelContentOverride: {
|
powerLevelContentOverride: {
|
||||||
// Only admins (power level 100) are allowed to post in a canal.
|
// All members may send messages so they can react and comment in the
|
||||||
'events_default': 100,
|
// publication threads. The app hides the publication composer for
|
||||||
|
// non-admins in the main canal feed instead.
|
||||||
|
'events_default': 0,
|
||||||
},
|
},
|
||||||
initialState: [
|
initialState: [
|
||||||
if (avatar != null)
|
if (avatar != null)
|
||||||
@@ -155,6 +158,7 @@ class NewGroupController extends State<NewGroup> {
|
|||||||
final canal = client.getRoomById(roomId);
|
final canal = client.getRoomById(roomId);
|
||||||
if (canal != null) {
|
if (canal != null) {
|
||||||
await canal.addTag(AppConfig.canalTag);
|
await canal.addTag(AppConfig.canalTag);
|
||||||
|
await canal.setRoomType('canal');
|
||||||
}
|
}
|
||||||
await _addToSpace(roomId);
|
await _addToSpace(roomId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -190,6 +194,7 @@ class NewGroupController extends State<NewGroup> {
|
|||||||
final mural = client.getRoomById(roomId);
|
final mural = client.getRoomById(roomId);
|
||||||
if (mural != null) {
|
if (mural != null) {
|
||||||
await mural.addTag(AppConfig.muralTag);
|
await mural.addTag(AppConfig.muralTag);
|
||||||
|
await mural.setRoomType('mural');
|
||||||
}
|
}
|
||||||
await _addToSpace(roomId);
|
await _addToSpace(roomId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:tatchat/utils/custom_http_client.dart';
|
|||||||
import 'package:tatchat/utils/custom_image_resizer.dart';
|
import 'package:tatchat/utils/custom_image_resizer.dart';
|
||||||
import 'package:tatchat/utils/init_with_restore.dart';
|
import 'package:tatchat/utils/init_with_restore.dart';
|
||||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
|
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
|
||||||
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/platform_infos.dart';
|
import 'package:tatchat/utils/platform_infos.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
@@ -130,6 +131,9 @@ abstract class ClientManager {
|
|||||||
'im.ponies.room_emotes',
|
'im.ponies.room_emotes',
|
||||||
// To display call state in chat list
|
// To display call state in chat list
|
||||||
MatrixRtcCallMember.eventType,
|
MatrixRtcCallMember.eventType,
|
||||||
|
// Make the canal/mural room type marker available in the chat list
|
||||||
|
// right after sync, even for rooms that are still partially loaded.
|
||||||
|
roomTypeStateEvent,
|
||||||
},
|
},
|
||||||
customImageResizer: PlatformInfos.supportsCustomImageResizer
|
customImageResizer: PlatformInfos.supportsCustomImageResizer
|
||||||
? customImageResizer
|
? customImageResizer
|
||||||
|
|||||||
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),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: Text(title),
|
child: Text(title),
|
||||||
),
|
),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 256),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: message == null
|
child: message == null
|
||||||
@@ -79,6 +81,8 @@ Future<OkCancelResult?> showOkAlertDialog({
|
|||||||
constraints: const BoxConstraints(maxWidth: 256),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: Text(title),
|
child: Text(title),
|
||||||
),
|
),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 256),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: message == null
|
child: message == null
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Future<String?> showTextInputDialog({
|
|||||||
constraints: const BoxConstraints(maxWidth: 256),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: Text(title),
|
child: Text(title),
|
||||||
),
|
),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
content: ConstrainedBox(
|
content: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 256),
|
constraints: const BoxConstraints(maxWidth: 256),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -1157,7 +1157,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
linkify:
|
linkify:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: linkify
|
name: linkify
|
||||||
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
|
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ dependencies:
|
|||||||
just_audio_media_kit: ^2.1.0
|
just_audio_media_kit: ^2.1.0
|
||||||
latlong2: ^0.10.1
|
latlong2: ^0.10.1
|
||||||
light_compressor_v2: ^1.9.1
|
light_compressor_v2: ^1.9.1
|
||||||
|
linkify: ^5.0.0
|
||||||
livekit_client: ^2.11.0
|
livekit_client: ^2.11.0
|
||||||
local_auth: ^3.0.2
|
local_auth: ^3.0.2
|
||||||
lottie: ^3.5.1
|
lottie: ^3.5.1
|
||||||
|
|||||||