feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

- 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:
2026-09-21 14:30:47 -03:00
parent 762178d9f9
commit 5a72c71cfc
14 changed files with 861 additions and 584 deletions

View File

@@ -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() {

View 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,
),
),
),
],
),
),
);
}
}

View File

@@ -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(

View File

@@ -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),
],
),

View File

@@ -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;

View File

@@ -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),
);
}

View File

@@ -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 }

View File

@@ -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,
),
],
);

View File

@@ -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,
);
},

View File

@@ -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') ??

View File

@@ -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),

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:matrix/matrix.dart';
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);
}
}

View 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 }