feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
- Salas (chat, canais, murais e salas de espacos) abrem bottom sheet com grid de acoes - Botao + da barra de digitacao abre painel de anexos em grid de 3 colunas que empurra a caixa de texto - Modal do perfil permite editar nome/status e ler QR code (mobile) - Alterar status atualiza localmente via extensao do SDK matrix - Mural pre-carrega historico para exibir todas as midias em dispositivos sem cache
This commit is contained in:
@@ -19,7 +19,7 @@ extension on TatuChatTester {
|
||||
await ensureLoggedIn();
|
||||
|
||||
// Login user 2
|
||||
await tapOn(Key('accounts_and_settings_buttons'));
|
||||
await tapOn(Icons.menu);
|
||||
await tapOn('Add account');
|
||||
await login(username: user2Name, password: user2Pw);
|
||||
await initCryptoIdentity(username: user2Name);
|
||||
|
||||
@@ -204,6 +204,8 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
|
||||
bool showEmojiPicker = false;
|
||||
|
||||
bool showAttachmentPicker = false;
|
||||
|
||||
String? get threadLastEventId {
|
||||
final threadId = activeThreadId;
|
||||
if (threadId == null) return null;
|
||||
@@ -978,18 +980,40 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
setState(() => showEmojiPicker = false);
|
||||
}
|
||||
|
||||
void hideAttachmentPicker() {
|
||||
setState(() => showAttachmentPicker = false);
|
||||
}
|
||||
|
||||
void emojiPickerAction() {
|
||||
if (showEmojiPicker) {
|
||||
inputFocus.requestFocus();
|
||||
} else {
|
||||
inputFocus.unfocus();
|
||||
}
|
||||
setState(() => showEmojiPicker = !showEmojiPicker);
|
||||
setState(() {
|
||||
showEmojiPicker = !showEmojiPicker;
|
||||
showAttachmentPicker = false;
|
||||
});
|
||||
}
|
||||
|
||||
void attachmentPickerAction() {
|
||||
if (showAttachmentPicker) {
|
||||
inputFocus.requestFocus();
|
||||
} else {
|
||||
inputFocus.unfocus();
|
||||
}
|
||||
setState(() {
|
||||
showAttachmentPicker = !showAttachmentPicker;
|
||||
showEmojiPicker = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _inputFocusListener() {
|
||||
if (showEmojiPicker && inputFocus.hasFocus) {
|
||||
setState(() => showEmojiPicker = false);
|
||||
if ((showEmojiPicker || showAttachmentPicker) && inputFocus.hasFocus) {
|
||||
setState(() {
|
||||
showEmojiPicker = false;
|
||||
showAttachmentPicker = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,6 +1040,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
|
||||
setState(() {
|
||||
showEmojiPicker = false;
|
||||
showAttachmentPicker = false;
|
||||
selectedEvents.clear();
|
||||
});
|
||||
}
|
||||
@@ -1044,6 +1069,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
showEmojiPicker = false;
|
||||
showAttachmentPicker = false;
|
||||
selectedEvents.clear();
|
||||
});
|
||||
scaffoldMessenger.showSnackBar(
|
||||
@@ -1120,6 +1146,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
);
|
||||
setState(() {
|
||||
showEmojiPicker = false;
|
||||
showAttachmentPicker = false;
|
||||
selectedEvents.clear();
|
||||
});
|
||||
}
|
||||
@@ -1305,6 +1332,7 @@ class ChatController extends State<ChatPageWithRoom>
|
||||
void clearSelectedEvents() => setState(() {
|
||||
selectedEvents.clear();
|
||||
showEmojiPicker = false;
|
||||
showAttachmentPicker = false;
|
||||
});
|
||||
|
||||
void clearSingleSelectedEvent() {
|
||||
|
||||
168
lib/pages/chat/chat_attachment_picker.dart
Normal file
168
lib/pages/chat/chat_attachment_picker.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class ChatAttachmentPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatAttachmentPicker(this.controller, {super.key});
|
||||
|
||||
static const double _itemSpacing = 8;
|
||||
static const double _handleSize = 4;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = L10n.of(context);
|
||||
final mobile = PlatformInfos.isMobile;
|
||||
|
||||
final items = <(IconData, String, AddPopupMenuActions)>[
|
||||
if (mobile)
|
||||
(
|
||||
Icons.gps_fixed_outlined,
|
||||
l10n.shareLocation,
|
||||
AddPopupMenuActions.location,
|
||||
),
|
||||
(Icons.poll_outlined, l10n.startPoll, AddPopupMenuActions.poll),
|
||||
if (mobile)
|
||||
(
|
||||
Icons.videocam_outlined,
|
||||
l10n.recordAVideo,
|
||||
AddPopupMenuActions.videoCamera,
|
||||
),
|
||||
if (mobile)
|
||||
(
|
||||
Icons.camera_alt_outlined,
|
||||
l10n.takeAPhoto,
|
||||
AddPopupMenuActions.photoCamera,
|
||||
),
|
||||
(Icons.image_outlined, l10n.openGallery, AddPopupMenuActions.media),
|
||||
(Icons.attachment_outlined, l10n.sendFile, AddPopupMenuActions.file),
|
||||
];
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final rows = <List<(IconData, String, AddPopupMenuActions)>>[];
|
||||
for (var i = 0; i < items.length; i += 3) {
|
||||
rows.add(items.sublist(i, i + 3 > items.length ? items.length : i + 3));
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
child: AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: controller.showAttachmentPicker
|
||||
? Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: _handleSize,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(_handleSize / 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
for (final (i, row) in rows.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: _itemSpacing),
|
||||
Row(
|
||||
children: [
|
||||
for (final item in row) ...[
|
||||
if (row.indexOf(item) > 0)
|
||||
const SizedBox(width: _itemSpacing),
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _AttachmentItem(
|
||||
icon: item.$1,
|
||||
label: item.$2,
|
||||
onTap: () {
|
||||
controller.hideAttachmentPicker();
|
||||
controller
|
||||
.onAddPopupMenuButtonSelected(
|
||||
item.$3,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (row.length < 3)
|
||||
for (var i = 0; i < 3 - row.length; i++) ...[
|
||||
const SizedBox(width: _itemSpacing),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _AttachmentItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 30, color: theme.colorScheme.onSecondaryContainer),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,102 +141,10 @@ class ChatInputRow extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: PopupMenuButton<AddPopupMenuActions>(
|
||||
useRootNavigator: true,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||
itemBuilder: (BuildContext context) => [
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.location,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.gps_fixed_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).shareLocation),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.poll,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.poll_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).startPoll),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
if (PlatformInfos.isMobile) ...[
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.videoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.videocam_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).recordAVideo),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.photoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.camera_alt_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).takeAPhoto),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
],
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.media,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.image_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).openGallery),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.file,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.attachment_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).sendFile),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
],
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
onPressed: controller.attachmentPickerAction,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../utils/matrix_sdk_extensions/room_extension.dart';
|
||||
import '../../utils/stream_extension.dart';
|
||||
import 'chat_attachment_picker.dart';
|
||||
import 'chat_emoji_picker.dart';
|
||||
import 'chat_input_row.dart';
|
||||
|
||||
@@ -57,6 +58,7 @@ class ChatView extends StatelessWidget {
|
||||
canPop:
|
||||
controller.selectedEvents.isEmpty &&
|
||||
!controller.showEmojiPicker &&
|
||||
!controller.showAttachmentPicker &&
|
||||
controller.activeThreadId == null,
|
||||
onPopInvokedWithResult: (pop, _) async {
|
||||
if (pop) return;
|
||||
@@ -64,6 +66,8 @@ class ChatView extends StatelessWidget {
|
||||
controller.clearSelectedEvents();
|
||||
} else if (controller.showEmojiPicker) {
|
||||
controller.emojiPickerAction();
|
||||
} else if (controller.showAttachmentPicker) {
|
||||
controller.attachmentPickerAction();
|
||||
} else if (controller.activeThreadId != null) {
|
||||
controller.closeThread();
|
||||
}
|
||||
@@ -473,6 +477,9 @@ class ChatView extends StatelessWidget {
|
||||
children: [
|
||||
ReplyDisplay(controller),
|
||||
ChatInputRow(controller),
|
||||
ChatAttachmentPicker(
|
||||
controller,
|
||||
),
|
||||
ChatEmojiPicker(controller),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -31,6 +31,14 @@ import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
|
||||
|
||||
import '../../utils/resize_video.dart';
|
||||
|
||||
/// Opens the crop/draw/rotate image editor with [bytes] and returns the edited
|
||||
/// PNG bytes, or null if the user cancelled or made no changes.
|
||||
Future<Uint8List?> editImageBytes(BuildContext context, Uint8List bytes) =>
|
||||
showScaffoldDialog<Uint8List?>(
|
||||
context: context,
|
||||
builder: (_) => _ImageEditPage(bytes: bytes),
|
||||
);
|
||||
|
||||
class SendFileDialog extends StatefulWidget {
|
||||
final Room room;
|
||||
final List<XFile> files;
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:tatchat/pages/chat_list/chat_list_view.dart';
|
||||
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/client_presence_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';
|
||||
@@ -30,7 +31,6 @@ import 'package:tatchat/utils/show_update_snackbar.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.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/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
|
||||
|
||||
@@ -514,21 +514,7 @@ class ChatListController extends State<ChatList>
|
||||
BuildContext posContext, [
|
||||
Room? space,
|
||||
]) async {
|
||||
final overlay =
|
||||
Overlay.of(posContext).context.findRenderObject() as RenderBox;
|
||||
|
||||
final button = posContext.findRenderObject() as RenderBox;
|
||||
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
|
||||
button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
|
||||
ancestor: overlay,
|
||||
),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
final l10n = L10n.of(posContext);
|
||||
|
||||
final spacesWithPowerLevels = room.client.rooms
|
||||
.where(
|
||||
@@ -539,202 +525,104 @@ class ChatListController extends State<ChatList>
|
||||
)
|
||||
.toList();
|
||||
|
||||
var action = await showMenu<ChatContextAction>(
|
||||
var action = await showModalActionPopup<ChatContextAction>(
|
||||
context: posContext,
|
||||
position: position,
|
||||
items: [
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: [
|
||||
if (space != null)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.goToSpace,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: space.avatar,
|
||||
size: Avatar.defaultSize / 2,
|
||||
name: space.getLocalizedDisplayname(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).goToSpace(space.getLocalizedDisplayname()),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: l10n.goToSpace(space.getLocalizedDisplayname()),
|
||||
icon: const Icon(Icons.workspaces_outlined),
|
||||
),
|
||||
if (room.membership == Membership.join) ...[
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.mute,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_off,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? L10n.of(context).muteChat
|
||||
: L10n.of(context).unmuteChat,
|
||||
),
|
||||
],
|
||||
label: room.pushRuleState == PushRuleState.notify
|
||||
? l10n.muteChat
|
||||
: l10n.unmuteChat,
|
||||
icon: Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_off,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.markUnread,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.markedUnread
|
||||
? L10n.of(context).markAsRead
|
||||
: L10n.of(context).markAsUnread,
|
||||
),
|
||||
],
|
||||
label: room.markedUnread ? l10n.markAsRead : l10n.markAsUnread,
|
||||
icon: Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
),
|
||||
if (!room.isLowPriority)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.favorite,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.isFavourite
|
||||
? L10n.of(context).unpin
|
||||
: L10n.of(context).pin,
|
||||
),
|
||||
],
|
||||
label: room.isFavourite ? l10n.unpin : l10n.pin,
|
||||
icon: Icon(
|
||||
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.leave,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outlined,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.membership == Membership.invite
|
||||
? L10n.of(context).delete
|
||||
: L10n.of(context).leave,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: room.membership == Membership.invite
|
||||
? l10n.delete
|
||||
: l10n.leave,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
if (room.membership == Membership.invite)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.block,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.block_outlined,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).block,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: l10n.block,
|
||||
icon: const Icon(Icons.block_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
if (room.membership == Membership.join)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.showMore,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(Icons.adaptive.more_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).more),
|
||||
],
|
||||
),
|
||||
label: l10n.more,
|
||||
icon: Icon(Icons.adaptive.more_outlined),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!posContext.mounted || !mounted) return;
|
||||
if (action == ChatContextAction.showMore) {
|
||||
action = await showMenu<ChatContextAction>(
|
||||
action = await showModalActionPopup<ChatContextAction>(
|
||||
context: posContext,
|
||||
position: position,
|
||||
items: [
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: [
|
||||
if (!room.isFavourite)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.lowPriority,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.isLowPriority
|
||||
? Icons.low_priority
|
||||
: Icons.low_priority_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.isLowPriority
|
||||
? L10n.of(context).unsetLowPriority
|
||||
: L10n.of(context).setLowPriority,
|
||||
),
|
||||
],
|
||||
label: room.isLowPriority
|
||||
? l10n.unsetLowPriority
|
||||
: l10n.setLowPriority,
|
||||
icon: Icon(
|
||||
room.isLowPriority
|
||||
? Icons.low_priority
|
||||
: Icons.low_priority_outlined,
|
||||
),
|
||||
),
|
||||
if (activeTag == null)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.addTag,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(Icons.bookmark_add_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).addTag),
|
||||
],
|
||||
),
|
||||
label: l10n.addTag,
|
||||
icon: const Icon(Icons.bookmark_add_outlined),
|
||||
)
|
||||
else
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.removeTag,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(Icons.bookmark_remove_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).removeTag),
|
||||
],
|
||||
),
|
||||
label: l10n.removeTag,
|
||||
icon: const Icon(Icons.bookmark_remove_outlined),
|
||||
),
|
||||
if (spacesWithPowerLevels.isNotEmpty)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: ChatContextAction.addToSpace,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.group_work_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).addToSpace),
|
||||
],
|
||||
),
|
||||
label: l10n.addToSpace,
|
||||
icon: const Icon(Icons.group_work_outlined),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -909,11 +797,7 @@ class ChatListController extends State<ChatList>
|
||||
if (!mounted) return;
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setPresence(
|
||||
client.userID!,
|
||||
PresenceType.online,
|
||||
statusMsg: input,
|
||||
),
|
||||
future: () => client.setStatusMessage(input),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,255 +4,46 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart' hide Result;
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/tatu_share.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/self_profile_dialog.dart';
|
||||
|
||||
import 'chat_list.dart';
|
||||
|
||||
/// The header avatar of the active account. Tapping it opens the own profile
|
||||
/// dialog. Account switching and the former avatar popup actions live in the
|
||||
/// navigation drawer (Sidebar).
|
||||
class ClientChooserButton extends StatelessWidget {
|
||||
final ChatListController controller;
|
||||
|
||||
const ClientChooserButton(this.controller, {super.key});
|
||||
|
||||
List<PopupMenuEntry<Object>> _bundleMenuItems(BuildContext context) {
|
||||
final matrix = Matrix.of(context);
|
||||
final bundles = matrix.accountBundles.keys.toList()
|
||||
..sort(
|
||||
(a, b) => a!.isValidMatrixIdStrict() == b!.isValidMatrixIdStrict()
|
||||
? 0
|
||||
: a.isValidMatrixIdStrict() && !b.isValidMatrixIdStrict()
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
if (FluffyThemes.isColumnMode(context)) {
|
||||
// In column mode the settings/actions live in the navigation sidebar,
|
||||
// so the avatar popup only offers the account switcher.
|
||||
return <PopupMenuEntry<Object>>[
|
||||
..._bundleEntries(context, matrix, bundles),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.addAccount,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_add_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).addAccount),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return <PopupMenuEntry<Object>>[
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.newGroup,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.group_add_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).createGroup),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.setStatus,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.edit_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).setStatus),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.invite,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.adaptive.share_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).inviteContact),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.archive,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.archive_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).archive),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.apps,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.apps_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).apps),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.settings,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.settings_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).settings),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
..._bundleEntries(context, matrix, bundles),
|
||||
PopupMenuItem(
|
||||
value: SettingsAction.addAccount,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_add_outlined),
|
||||
const SizedBox(width: 18),
|
||||
Text(L10n.of(context).addAccount),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<PopupMenuEntry<Object>> _bundleEntries(
|
||||
BuildContext context,
|
||||
MatrixState matrix,
|
||||
List<String?> bundles,
|
||||
) {
|
||||
return <PopupMenuEntry<Object>>[
|
||||
for (final bundle in bundles) ...[
|
||||
if (matrix.accountBundles[bundle]!.length != 1 ||
|
||||
matrix.accountBundles[bundle]!.single!.userID != bundle)
|
||||
PopupMenuItem(
|
||||
value: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
bundle!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).textTheme.titleMedium!.color,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
...matrix.accountBundles[bundle]!
|
||||
.whereType<Client>()
|
||||
.where((client) => client.isLogged())
|
||||
.map(
|
||||
(client) => PopupMenuItem(
|
||||
value: client,
|
||||
child: FutureBuilder<Profile?>(
|
||||
future: client.fetchOwnProfile(),
|
||||
builder: (context, snapshot) {
|
||||
final displayname =
|
||||
snapshot.data?.displayName ?? client.userID!.localpart!;
|
||||
return Row(
|
||||
key: ValueKey('switch_account_$displayname'),
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name: displayname,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayname,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => controller.editBundlesForAccount(
|
||||
client.userID,
|
||||
bundle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final matrix = Matrix.of(context);
|
||||
final client = Result(() => matrix.client).asValue?.value;
|
||||
|
||||
var clientCount = 0;
|
||||
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
|
||||
return FutureBuilder<Profile>(
|
||||
future: client?.isLogged() == true ? client?.fetchOwnProfile() : null,
|
||||
builder: (context, snapshot) => Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
color: Colors.transparent,
|
||||
child: PopupMenuButton<Object>(
|
||||
key: Key('accounts_and_settings_buttons'),
|
||||
tooltip: L10n.of(context).accountsAndSettings,
|
||||
onSelected: (o) => _clientSelected(o, context),
|
||||
itemBuilder: _bundleMenuItems,
|
||||
icon: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name: snapshot.data?.displayName ?? client?.userID?.localpart,
|
||||
size: 32,
|
||||
child: Tooltip(
|
||||
message: L10n.of(context).accountsAndSettings,
|
||||
child: InkWell(
|
||||
key: const Key('accounts_and_settings_buttons'),
|
||||
onTap: () => showSelfProfileDialog(context),
|
||||
child: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name: snapshot.data?.displayName ?? client?.userID?.localpart,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clientSelected(Object object, BuildContext context) async {
|
||||
if (object is Client) {
|
||||
controller.setActiveClient(object);
|
||||
} else if (object is String) {
|
||||
controller.setActiveBundle(object);
|
||||
} else if (object is SettingsAction) {
|
||||
switch (object) {
|
||||
case SettingsAction.addAccount:
|
||||
if (!context.mounted) return;
|
||||
context.go('/rooms/settings/addaccount');
|
||||
break;
|
||||
case SettingsAction.newGroup:
|
||||
context.go('/rooms/newgroup');
|
||||
break;
|
||||
case SettingsAction.invite:
|
||||
TatuShare.shareInviteLink(context);
|
||||
break;
|
||||
case SettingsAction.settings:
|
||||
context.go('/rooms/settings');
|
||||
break;
|
||||
case SettingsAction.archive:
|
||||
context.go('/rooms/archive');
|
||||
break;
|
||||
case SettingsAction.apps:
|
||||
context.go('/rooms/apps');
|
||||
break;
|
||||
case SettingsAction.setStatus:
|
||||
controller.setStatus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsAction { addAccount, newGroup, setStatus, invite, settings, archive, apps }
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/utils/string_color.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
@@ -219,108 +220,50 @@ class _SpaceViewState extends State<SpaceView> {
|
||||
final space = client.getRoomById(widget.spaceId);
|
||||
final room = client.getRoomById(roomId);
|
||||
if (space == null) return;
|
||||
final overlay =
|
||||
Overlay.of(posContext).context.findRenderObject() as RenderBox;
|
||||
final l10n = L10n.of(posContext);
|
||||
|
||||
final button = posContext.findRenderObject() as RenderBox;
|
||||
|
||||
final position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
button.localToGlobal(const Offset(0, -65), ancestor: overlay),
|
||||
button.localToGlobal(
|
||||
button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
|
||||
ancestor: overlay,
|
||||
),
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
|
||||
final action = await showMenu<SpaceChildAction>(
|
||||
final action = await showModalActionPopup<SpaceChildAction>(
|
||||
context: posContext,
|
||||
position: position,
|
||||
items: [
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: [
|
||||
if (room != null && room.membership == Membership.join) ...[
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: room.pushRuleState == PushRuleState.notify
|
||||
? SpaceChildAction.mute
|
||||
: SpaceChildAction.unmute,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_on_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? L10n.of(context).muteChat
|
||||
: L10n.of(context).unmuteChat,
|
||||
),
|
||||
],
|
||||
label: room.pushRuleState == PushRuleState.notify
|
||||
? l10n.muteChat
|
||||
: l10n.unmuteChat,
|
||||
icon: Icon(
|
||||
room.pushRuleState == PushRuleState.notify
|
||||
? Icons.notifications_off_outlined
|
||||
: Icons.notifications_on_outlined,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: room.markedUnread
|
||||
? SpaceChildAction.markAsRead
|
||||
: SpaceChildAction.markAsUnread,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
room.isUnread
|
||||
? L10n.of(context).markAsRead
|
||||
: L10n.of(context).markAsUnread,
|
||||
),
|
||||
],
|
||||
label: room.isUnread ? l10n.markAsRead : l10n.markAsUnread,
|
||||
icon: Icon(
|
||||
room.markedUnread
|
||||
? Icons.mark_as_unread
|
||||
: Icons.mark_as_unread_outlined,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: SpaceChildAction.leave,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outlined,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).leave,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: l10n.leave,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
if (space.canChangeStateEvent(EventTypes.SpaceChild) == true)
|
||||
PopupMenuItem(
|
||||
AdaptiveModalAction(
|
||||
value: SpaceChildAction.removeFromSpace,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.remove,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
L10n.of(context).removeFromSpace,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
label: l10n.removeFromSpace,
|
||||
icon: const Icon(Icons.remove),
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -95,6 +95,8 @@ class _ChannelsTabState extends State<ChannelsTab> {
|
||||
room,
|
||||
key: Key('channel_list_item_${room.id}'),
|
||||
onTap: () => widget.controller.onChatTap(room),
|
||||
onLongPress: (context) =>
|
||||
widget.controller.chatContextAction(room, context),
|
||||
activeChat: widget.controller.activeChat == room.id,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -101,6 +101,8 @@ class _MuralsTabState extends State<MuralsTab> {
|
||||
room,
|
||||
key: Key('mural_list_item_${room.id}'),
|
||||
onTap: () => widget.controller.onChatTap(room),
|
||||
onLongPress: (context) =>
|
||||
widget.controller.chatContextAction(room, context),
|
||||
activeChat: widget.controller.activeChat == room.id,
|
||||
displayAvatar: Uri.tryParse(
|
||||
creatorState?.content.tryGet<String>('avatar_url') ??
|
||||
|
||||
@@ -88,6 +88,7 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
bool _descriptionExpanded = false;
|
||||
bool _loadingHistory = false;
|
||||
bool _historyEnded = false;
|
||||
bool _initialLoading = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -125,7 +126,8 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
} catch (e, s) {
|
||||
Logs().w('Failed to load more mural posts', e, s);
|
||||
} finally {
|
||||
if (timeline.events.length <= eventCountBefore) {
|
||||
if (!timeline.canRequestHistory ||
|
||||
timeline.events.length <= eventCountBefore) {
|
||||
_historyEnded = true;
|
||||
}
|
||||
_loadingHistory = false;
|
||||
@@ -133,6 +135,25 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches older events from the server until enough media posts were
|
||||
/// loaded or the entire history was consumed. This makes the mural behave
|
||||
/// like a chat: after the initial sync only a handful of recent events are
|
||||
/// in the timeline which usually contain very few media posts (most events
|
||||
/// are thread comments), so we must actively fetch batches from the server
|
||||
/// until the feed is properly populated.
|
||||
Future<void> _fillInitialMedia() async {
|
||||
var attempts = 0;
|
||||
const maxAttempts = 20;
|
||||
const targetMediaCount = 30;
|
||||
while (mounted &&
|
||||
!_historyEnded &&
|
||||
_mediaEvents.length < targetMediaCount &&
|
||||
attempts < maxAttempts) {
|
||||
attempts++;
|
||||
await _loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
void updateView() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
@@ -142,12 +163,16 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
timeline?.cancelSubscriptions();
|
||||
_loadingHistory = false;
|
||||
_historyEnded = false;
|
||||
_initialLoading = true;
|
||||
timeline = await room.getTimeline(
|
||||
onUpdate: updateView,
|
||||
limit: Room.defaultHistoryCount * 3,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
await _fillInitialMedia();
|
||||
_initialLoading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _sendFiles(List<XFile>? files) async {
|
||||
@@ -318,7 +343,9 @@ class _MuralChatViewState extends State<MuralChatView> {
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: events.isEmpty
|
||||
child: _initialLoading
|
||||
? const Center(child: CircularProgressIndicator.adaptive())
|
||||
: events.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
||||
@@ -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';
|
||||
|
||||
extension ClientPresenceExtension on Client {
|
||||
/// Sets the own presence status message.
|
||||
///
|
||||
/// The SDK's [Client.setPresence] only performs the HTTP request and does not
|
||||
/// update the local presence cache, the database or the
|
||||
/// [Client.onPresenceChanged] stream. As a result
|
||||
/// [Client.fetchCurrentPresence] keeps returning the previous status until the
|
||||
/// homeserver echoes it back over `/sync`. This helper updates all local
|
||||
/// caches after a successful request so the new status is shown immediately.
|
||||
Future<void> setStatusMessage(String statusMsg) async {
|
||||
final userId = userID;
|
||||
if (userId == null) return;
|
||||
await setPresence(userId, PresenceType.online, statusMsg: statusMsg);
|
||||
final presence = CachedPresence(
|
||||
PresenceType.online,
|
||||
null,
|
||||
statusMsg,
|
||||
null,
|
||||
userId,
|
||||
);
|
||||
// ignore: deprecated_member_use
|
||||
presences[userId] = presence;
|
||||
await database.storePresence(userId, presence);
|
||||
onPresenceChanged.add(presence);
|
||||
}
|
||||
}
|
||||
476
lib/widgets/self_profile_dialog.dart
Normal file
476
lib/widgets/self_profile_dialog.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:pretty_qr_code/pretty_qr_code.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:tatchat/pages/new_private_chat/qr_scanner_modal.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/file_selector.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/client_presence_extension.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/mxc_image_viewer.dart';
|
||||
import 'package:tatchat/widgets/qr_code_viewer.dart';
|
||||
|
||||
/// Shows the profile of the active account: avatar, display name, status, the
|
||||
/// own QR code, the global user ID and an optional QR code scanner.
|
||||
Future<void> showSelfProfileDialog(BuildContext context) => showAdaptiveDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => const SelfProfileDialog(),
|
||||
);
|
||||
|
||||
class SelfProfileDialog extends StatefulWidget {
|
||||
const SelfProfileDialog({super.key});
|
||||
|
||||
@override
|
||||
State<SelfProfileDialog> createState() => _SelfProfileDialogState();
|
||||
}
|
||||
|
||||
class _SelfProfileDialogState extends State<SelfProfileDialog> {
|
||||
Future<Profile>? _profileFuture;
|
||||
String? _statusMsg;
|
||||
bool _copied = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatus());
|
||||
}
|
||||
|
||||
/// The SDK does not update its presence cache after [Client.setPresence], so
|
||||
/// the status is kept in local state and only fetched once when opening.
|
||||
Future<void> _loadStatus() async {
|
||||
final client = Matrix.of(context).client;
|
||||
final userId = client.userID;
|
||||
if (userId == null) return;
|
||||
try {
|
||||
final presence = await client.fetchCurrentPresence(userId);
|
||||
if (!mounted) return;
|
||||
setState(() => _statusMsg = presence.statusMsg);
|
||||
} catch (_) {
|
||||
// Presence is optional, ignore failures and keep the status empty.
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
setState(() => _profileFuture = null);
|
||||
}
|
||||
|
||||
Future<void> _editAvatar() async {
|
||||
final l10n = L10n.of(context);
|
||||
final client = Matrix.of(context).client;
|
||||
final profile = await _profileFuture;
|
||||
if (!mounted) return;
|
||||
final actions = [
|
||||
if (PlatformInfos.isMobile)
|
||||
AdaptiveModalAction(
|
||||
value: _AvatarAction.camera,
|
||||
label: l10n.openCamera,
|
||||
isDefaultAction: true,
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
),
|
||||
AdaptiveModalAction(
|
||||
value: _AvatarAction.file,
|
||||
label: l10n.openGallery,
|
||||
icon: const Icon(Icons.photo_outlined),
|
||||
),
|
||||
if (profile?.avatarUrl != null)
|
||||
AdaptiveModalAction(
|
||||
value: _AvatarAction.remove,
|
||||
label: l10n.removeYourAvatar,
|
||||
isDestructive: true,
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
),
|
||||
];
|
||||
final action = actions.length == 1
|
||||
? actions.single.value
|
||||
: await showModalActionPopup<_AvatarAction>(
|
||||
context: context,
|
||||
title: l10n.changeYourAvatar,
|
||||
cancelLabel: l10n.cancel,
|
||||
actions: actions,
|
||||
);
|
||||
if (action == null) return;
|
||||
if (!mounted) return;
|
||||
if (action == _AvatarAction.remove) {
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setAvatar(null),
|
||||
);
|
||||
if (success.error == null) _refresh();
|
||||
return;
|
||||
}
|
||||
final Uint8List bytes;
|
||||
final String name;
|
||||
if (PlatformInfos.isMobile) {
|
||||
final result = await ImagePicker().pickImage(
|
||||
source: action == _AvatarAction.camera
|
||||
? ImageSource.camera
|
||||
: ImageSource.gallery,
|
||||
imageQuality: 50,
|
||||
);
|
||||
if (result == null) return;
|
||||
bytes = await result.readAsBytes();
|
||||
name = result.path;
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
final result = await selectFiles(context, type: FileType.image);
|
||||
final pickedFile = result.firstOrNull;
|
||||
if (pickedFile == null) return;
|
||||
bytes = await pickedFile.readAsBytes();
|
||||
name = pickedFile.name;
|
||||
}
|
||||
if (!mounted) return;
|
||||
final edited = await editImageBytes(context, bytes);
|
||||
if (!mounted) return;
|
||||
final file = MatrixFile(
|
||||
bytes: edited ?? bytes,
|
||||
name: edited != null ? 'avatar.png' : name,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setAvatar(file),
|
||||
);
|
||||
if (success.error == null) _refresh();
|
||||
}
|
||||
|
||||
Future<void> _editDisplayName() async {
|
||||
final l10n = L10n.of(context);
|
||||
final client = Matrix.of(context).client;
|
||||
final profile = await _profileFuture;
|
||||
if (!mounted) return;
|
||||
final input = await showTextInputDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: l10n.editDisplayname,
|
||||
okLabel: l10n.ok,
|
||||
cancelLabel: l10n.cancel,
|
||||
initialText: profile?.displayName ?? client.userID!.localpart,
|
||||
);
|
||||
if (input == null) return;
|
||||
if (!mounted) return;
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setProfileField(client.userID!, 'displayname', {
|
||||
'displayname': input,
|
||||
}),
|
||||
);
|
||||
if (success.error == null) _refresh();
|
||||
}
|
||||
|
||||
Future<void> _editStatus() async {
|
||||
final l10n = L10n.of(context);
|
||||
final client = Matrix.of(context).client;
|
||||
final input = await showTextInputDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: l10n.setStatus,
|
||||
message: l10n.leaveEmptyToClearStatus,
|
||||
okLabel: l10n.ok,
|
||||
cancelLabel: l10n.cancel,
|
||||
hintText: l10n.statusExampleMessage,
|
||||
maxLines: 6,
|
||||
minLines: 1,
|
||||
maxLength: 255,
|
||||
initialText: _statusMsg,
|
||||
);
|
||||
if (input == null) return;
|
||||
if (!mounted) return;
|
||||
final success = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.setStatusMessage(input),
|
||||
);
|
||||
if (success.error != null) return;
|
||||
if (!mounted) return;
|
||||
setState(() => _statusMsg = input);
|
||||
}
|
||||
|
||||
Future<void> _copyUserId() async {
|
||||
final client = Matrix.of(context).client;
|
||||
await Clipboard.setData(ClipboardData(text: client.userID!));
|
||||
if (!mounted) return;
|
||||
setState(() => _copied = true);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(L10n.of(context).copiedToClipboard)));
|
||||
}
|
||||
|
||||
Future<void> _openQrScanner() async {
|
||||
final context = this.context;
|
||||
await showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => QrScannerModal(
|
||||
onScan: (link) => UrlLauncher(context, link).openMatrixToUrl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openSettingsAction() async {
|
||||
Navigator.of(context).pop();
|
||||
if (context.mounted) context.go('/rooms/settings');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final matrix = Matrix.of(context);
|
||||
final client = matrix.client;
|
||||
final userId = client.userID;
|
||||
final l10n = L10n.of(context);
|
||||
_profileFuture ??= client.fetchOwnProfile();
|
||||
return AlertDialog.adaptive(
|
||||
content: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
FutureBuilder<Profile>(
|
||||
future: _profileFuture,
|
||||
builder: (context, snapshot) {
|
||||
final profile = snapshot.data;
|
||||
final avatar = profile?.avatarUrl;
|
||||
final displayname =
|
||||
profile?.displayName ?? userId?.localpart ?? l10n.user;
|
||||
return Column(
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
alignment: .center,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: avatar,
|
||||
name: displayname,
|
||||
size: 96,
|
||||
shapeBorder: const CircleBorder(),
|
||||
onTap: avatar != null
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => MxcImageViewer(avatar),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Material(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: _editAvatar,
|
||||
child: SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_EditableLine(
|
||||
text: displayname,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
tooltip: l10n.editDisplayname,
|
||||
onEdit: _editDisplayName,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_EditableLine(
|
||||
text: _statusMsg?.trim().isNotEmpty == true
|
||||
? _statusMsg!
|
||||
: l10n.setStatus,
|
||||
style: _statusMsg?.trim().isNotEmpty == true
|
||||
? theme.textTheme.bodyMedium
|
||||
: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
tooltip: l10n.setStatus,
|
||||
onEdit: _editStatus,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (userId != null) ...[
|
||||
Center(
|
||||
child: Material(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
side: BorderSide(
|
||||
width: 2,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
onTap: () => showQrCodeViewer(context, userId),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: PrettyQrView.data(
|
||||
data: 'https://matrix.to/#/$userId',
|
||||
decoration: PrettyQrDecoration(
|
||||
shape: PrettyQrSmoothSymbol(
|
||||
roundFactor: 1,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
onTap: _copyUserId,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
userId,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'RobotoMono',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_copied
|
||||
? Icons.check_circle
|
||||
: Icons.copy_outlined,
|
||||
size: 14,
|
||||
color: _copied
|
||||
? Colors.green
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Divider(height: 32),
|
||||
if (PlatformInfos.isMobile)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
child: const Icon(Icons.qr_code_scanner_outlined),
|
||||
),
|
||||
title: Text(l10n.scanQrCode),
|
||||
onTap: _openQrScanner,
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: Text(l10n.settings),
|
||||
onTap: _openSettingsAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single line of profile data (displayname/status) with a small edit
|
||||
/// button that invokes [onEdit] when tapped.
|
||||
class _EditableLine extends StatelessWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final String tooltip;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const _EditableLine({
|
||||
required this.text,
|
||||
required this.style,
|
||||
required this.tooltip,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 3,
|
||||
overflow: .ellipsis,
|
||||
textAlign: .center,
|
||||
style: style,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
iconSize: 16,
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _AvatarAction { camera, file, remove }
|
||||
Reference in New Issue
Block a user