Initial commit
This commit is contained in:
68
lib/config/app_config.dart
Normal file
68
lib/config/app_config.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
abstract class AppConfig {
|
||||
static const Color primaryColor = Color(0xFF00D084);
|
||||
|
||||
static const Color chatColor = primaryColor;
|
||||
static const double messageFontSize = 16.0;
|
||||
static const bool allowOtherHomeservers = true;
|
||||
static const bool enableRegistration = true;
|
||||
static const bool hideTypingUsernames = false;
|
||||
|
||||
static const String inviteLinkPrefix = 'https://matrix.to/#/';
|
||||
static const String deepLinkPrefix = 'im.tatuchat://chat/';
|
||||
static const String schemePrefix = 'matrix:';
|
||||
static const String pushNotificationsChannelId = 'tatuchat_push';
|
||||
static const String pushNotificationsAppId = 'chat.fluffy.tatuchat';
|
||||
static const double borderRadius = 18.0;
|
||||
static const double spaceBorderRadius = 11.0;
|
||||
static const double columnWidth = 360.0;
|
||||
|
||||
static const String enablePushTutorial =
|
||||
'https://ko-fi.com/post/How-can-I-get-Push-Notifications-without-Google-N7Q825URG6?fromEditor=true';
|
||||
static const String encryptionTutorial =
|
||||
'https://ko-fi.com/post/How-to-use-end-to-end-encryption-in-FluffyChat-A5O725WDR5';
|
||||
static const String howDoIGetStickersTutorial =
|
||||
'https://ko-fi.com/post/How-to-add-a-sticker-pack-to-FluffyChat-N4N01OXATI';
|
||||
static const String appId = 'im.tatuchat.TatuChat';
|
||||
static const String appOpenUrlScheme = 'im.tatuchat';
|
||||
static const String appSsoUrlScheme = 'im.tatuchat.auth';
|
||||
|
||||
static const String sourceCodeUrl =
|
||||
'https://github.com/krille-chan/fluffychat';
|
||||
static const String supportUrl =
|
||||
'https://github.com/krille-chan/fluffychat/issues';
|
||||
static const String changelogUrl = 'https://fluffychat.im/changelog/';
|
||||
static const String helpUrl =
|
||||
'https://ko-fi.com/post/How-can-I-support-FluffyChat-J2G325WE6I';
|
||||
|
||||
static const Set<String> defaultReactions = {'👍', '❤️', '😂', '😮', '😢'};
|
||||
|
||||
static final Uri newIssueUrl = Uri(
|
||||
scheme: 'https',
|
||||
host: 'github.com',
|
||||
path: '/krille-chan/fluffychat/issues/new',
|
||||
);
|
||||
|
||||
static final Uri homeserverList = Uri(
|
||||
scheme: 'https',
|
||||
host: 'raw.githubusercontent.com',
|
||||
path: 'krille-chan/fluffychat/refs/heads/main/recommended_homeservers.json',
|
||||
);
|
||||
|
||||
static final Uri crashReportEndpoint = Uri(
|
||||
scheme: 'https',
|
||||
host: 'crash.fluffy.chat',
|
||||
);
|
||||
|
||||
static const String mainIsolatePortName = 'main_isolate';
|
||||
static const String pushIsolatePortName = 'push_isolate';
|
||||
static const String pushHelperCrashReportKey = 'push_helper_crash_report';
|
||||
|
||||
static const String vodozemacVersion = '0.8.1';
|
||||
}
|
||||
37
lib/config/isrg_x1.dart
Normal file
37
lib/config/isrg_x1.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
const String ISRG_X1 = '''-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----''';
|
||||
20
lib/config/isrg_x2.dart
Normal file
20
lib/config/isrg_x2.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
const String ISRG_X2 = '''-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----''';
|
||||
551
lib/config/routes.dart
Normal file
551
lib/config/routes.dart
Normal file
@@ -0,0 +1,551 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/pages/archive/archive.dart';
|
||||
import 'package:tatchat/pages/bootstrap/bootstrap_page.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat_access_settings/chat_access_settings_controller.dart';
|
||||
import 'package:tatchat/pages/chat_details/chat_details.dart';
|
||||
import 'package:tatchat/pages/chat_encryption_settings/chat_encryption_settings.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list.dart';
|
||||
import 'package:tatchat/pages/chat_members/chat_members.dart';
|
||||
import 'package:tatchat/pages/chat_permissions_settings/chat_permissions_settings.dart';
|
||||
import 'package:tatchat/pages/chat_search/chat_search_page.dart';
|
||||
import 'package:tatchat/pages/device_settings/device_settings.dart';
|
||||
import 'package:tatchat/pages/intro/intro_page_presenter.dart';
|
||||
import 'package:tatchat/pages/invitation_selection/invitation_selection.dart';
|
||||
import 'package:tatchat/pages/login/login.dart';
|
||||
import 'package:tatchat/pages/new_group/new_group.dart';
|
||||
import 'package:tatchat/pages/new_private_chat/new_private_chat.dart';
|
||||
import 'package:tatchat/pages/register/registration.dart';
|
||||
import 'package:tatchat/pages/settings/settings.dart';
|
||||
import 'package:tatchat/pages/settings_3pid/settings_3pid.dart';
|
||||
import 'package:tatchat/pages/settings_chat/settings_chat.dart';
|
||||
import 'package:tatchat/pages/settings_emotes/settings_emotes.dart';
|
||||
import 'package:tatchat/pages/settings_homeserver/settings_homeserver.dart';
|
||||
import 'package:tatchat/pages/settings_ignore_list/settings_ignore_list.dart';
|
||||
import 'package:tatchat/pages/settings_notifications/settings_notifications.dart';
|
||||
import 'package:tatchat/pages/settings_password/settings_password.dart';
|
||||
import 'package:tatchat/pages/settings_security/settings_security.dart';
|
||||
import 'package:tatchat/pages/settings_style/settings_style.dart';
|
||||
import 'package:tatchat/pages/sign_in/sign_in_page.dart';
|
||||
import 'package:tatchat/widgets/config_viewer.dart';
|
||||
import 'package:tatchat/widgets/layouts/empty_page.dart';
|
||||
import 'package:tatchat/widgets/layouts/two_column_layout.dart';
|
||||
import 'package:tatchat/widgets/log_view.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
abstract class AppRoutes {
|
||||
static FutureOr<String?> loggedInRedirect(
|
||||
BuildContext context,
|
||||
GoRouterState state,
|
||||
) => Matrix.of(context).widget.clients.any((client) => client.isLogged())
|
||||
? '/rooms'
|
||||
: null;
|
||||
|
||||
static FutureOr<String?> loggedOutRedirect(
|
||||
BuildContext context,
|
||||
GoRouterState state,
|
||||
) => Matrix.of(context).widget.clients.any((client) => client.isLogged())
|
||||
? null
|
||||
: '/home';
|
||||
|
||||
AppRoutes();
|
||||
|
||||
static final List<RouteBase> routes = [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
redirect: (context, state) =>
|
||||
Matrix.of(context).widget.clients.any((client) => client.isLogged())
|
||||
? '/rooms'
|
||||
: '/home',
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, const IntroPagePresenter()),
|
||||
redirect: loggedInRedirect,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'sign_in',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, SignInPage(signUp: false)),
|
||||
redirect: loggedInRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'sign_up',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, SignInPage(signUp: true)),
|
||||
redirect: loggedInRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'login',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
Login(client: state.extra as Client),
|
||||
),
|
||||
redirect: loggedInRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'register',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
RegistrationPage(client: state.extra as Client),
|
||||
),
|
||||
redirect: loggedInRedirect,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/logs',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, const LogViewer()),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/configs',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, const ConfigViewer()),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/backup',
|
||||
redirect: loggedOutRedirect,
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
BootstrapPage(reset: state.uri.queryParameters['reset'] == 'true'),
|
||||
),
|
||||
),
|
||||
ShellRoute(
|
||||
// Never use a transition on the shell route. Changing the PageBuilder
|
||||
// here based on a MediaQuery causes the child to briefly be rendered
|
||||
// twice with the same GlobalKey, blowing up the rendering.
|
||||
pageBuilder: (context, state, child) => noTransitionPageBuilder(
|
||||
context,
|
||||
state,
|
||||
FluffyThemes.isColumnMode(context) &&
|
||||
state.fullPath?.startsWith('/rooms/settings') == false
|
||||
? TwoColumnLayout(
|
||||
mainView: ChatList(
|
||||
activeChat: state.pathParameters['roomid'],
|
||||
activeSpace: state.uri.queryParameters['spaceId'],
|
||||
displayNavigationRail:
|
||||
state.path?.startsWith('/rooms/settings') != true,
|
||||
),
|
||||
sideView: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/rooms',
|
||||
redirect: loggedOutRedirect,
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
FluffyThemes.isColumnMode(context)
|
||||
? const EmptyPage()
|
||||
: ChatList(
|
||||
activeChat: state.pathParameters['roomid'],
|
||||
activeSpace: state.uri.queryParameters['spaceId'],
|
||||
),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'archive',
|
||||
pageBuilder: (context, state) =>
|
||||
defaultPageBuilder(context, state, const Archive()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':roomid',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatPage(
|
||||
key: ValueKey(state.uri),
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
eventId: state.uri.queryParameters['event'],
|
||||
timeline: state.extra as Timeline?,
|
||||
action: state.uri.queryParameters['action'],
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'newprivatechat',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
NewPrivateChat(
|
||||
key: ValueKey('new_chat_${state.uri.fragment}'),
|
||||
deeplink: state.uri.fragment,
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'newgroup',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
NewGroup(spaceId: state.uri.queryParameters['space_id']),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'newspace',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
NewGroup(
|
||||
createGroupType: CreateGroupType.space,
|
||||
spaceId: state.uri.queryParameters['space_id'],
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
ShellRoute(
|
||||
pageBuilder: (context, state, child) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
FluffyThemes.isColumnMode(context)
|
||||
? TwoColumnLayout(
|
||||
mainView: Settings(key: state.pageKey),
|
||||
sideView: child,
|
||||
hasNavigationRail: false,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
FluffyThemes.isColumnMode(context)
|
||||
? const EmptyPage()
|
||||
: const Settings(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'notifications',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsNotifications(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'style',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsStyle(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'devices',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const DevicesSettings(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'chat',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsChat(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'emotes',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
EmotesSettings(
|
||||
roomId: state.pathParameters['roomid'],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'addaccount',
|
||||
redirect: loggedOutRedirect,
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const IntroPagePresenter(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'sign_in',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
SignInPage(signUp: false),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'sign_up',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
SignInPage(signUp: true),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'login',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
Login(client: state.extra as Client),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'register',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
RegistrationPage(client: state.extra as Client),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'homeserver',
|
||||
pageBuilder: (context, state) {
|
||||
return defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsHomeserver(),
|
||||
);
|
||||
},
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'security',
|
||||
redirect: loggedOutRedirect,
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsSecurity(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'password',
|
||||
pageBuilder: (context, state) {
|
||||
return defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const SettingsPassword(),
|
||||
);
|
||||
},
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ignorelist',
|
||||
pageBuilder: (context, state) {
|
||||
return defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
SettingsIgnoreList(
|
||||
initialUserId: state.extra?.toString(),
|
||||
),
|
||||
);
|
||||
},
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: '3pid',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const Settings3Pid(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: ':roomid',
|
||||
pageBuilder: (context, state) {
|
||||
final body = state.uri.queryParameters['body'];
|
||||
var shareItems = state.extra is List<ShareItem>
|
||||
? state.extra as List<ShareItem>
|
||||
: null;
|
||||
if (body != null && body.isNotEmpty) {
|
||||
shareItems ??= [];
|
||||
shareItems.add(TextShareItem(body));
|
||||
}
|
||||
return defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatPage(
|
||||
key: ValueKey(state.uri),
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
shareItems: shareItems,
|
||||
eventId: state.uri.queryParameters['event'],
|
||||
action: state.uri.queryParameters['action'],
|
||||
),
|
||||
);
|
||||
},
|
||||
redirect: loggedOutRedirect,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'search',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatSearchPage(roomId: state.pathParameters['roomid']!),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'encryption',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const ChatEncryptionSettings(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'invite',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
InvitationSelection(
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'details',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatDetails(roomId: state.pathParameters['roomid']!),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'access',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatAccessSettings(
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'members',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
ChatMembersPage(
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'permissions',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
const ChatPermissionsSettings(),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'invite',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
InvitationSelection(
|
||||
roomId: state.pathParameters['roomid']!,
|
||||
),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
GoRoute(
|
||||
path: 'emotes',
|
||||
pageBuilder: (context, state) => defaultPageBuilder(
|
||||
context,
|
||||
state,
|
||||
EmotesSettings(roomId: state.pathParameters['roomid']),
|
||||
),
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
redirect: loggedOutRedirect,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
static Page noTransitionPageBuilder(
|
||||
BuildContext context,
|
||||
GoRouterState state,
|
||||
Widget child,
|
||||
) => NoTransitionPage(
|
||||
key: state.pageKey,
|
||||
restorationId: state.pageKey.value,
|
||||
child: child,
|
||||
);
|
||||
|
||||
static Page defaultPageBuilder(
|
||||
BuildContext context,
|
||||
GoRouterState state,
|
||||
Widget child,
|
||||
) {
|
||||
final clientName = state.uri.queryParameters['client'];
|
||||
if (clientName != null) {
|
||||
final matrix = Matrix.of(context);
|
||||
final client = matrix.getClientByName(clientName);
|
||||
if (client != null) matrix.setActiveClient(client);
|
||||
}
|
||||
return FluffyThemes.isColumnMode(context)
|
||||
? noTransitionPageBuilder(context, state, child)
|
||||
: MaterialPage(
|
||||
key: state.pageKey,
|
||||
restorationId: state.pageKey.value,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
264
lib/config/setting_keys.dart
Normal file
264
lib/config/setting_keys.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:managed_configurations/managed_configurations.dart';
|
||||
import 'package:matrix/matrix_api_lite/utils/logs.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum AppSettings<T> {
|
||||
textMessageMaxLength<int>('textMessageMaxLength', 16384),
|
||||
|
||||
/// Max lines for unselected HTML/text bubbles; 0 = unlimited (no fade).
|
||||
messagePreviewMaxLines<int>('chat.fluffy.message_preview_max_lines', 50),
|
||||
audioRecordingNumChannels<int>('audioRecordingNumChannels', 1),
|
||||
audioRecordingAutoGain<bool>('audioRecordingAutoGain', true),
|
||||
audioRecordingEchoCancel<bool>('audioRecordingEchoCancel', false),
|
||||
audioRecordingNoiseSuppress<bool>('audioRecordingNoiseSuppress', true),
|
||||
audioRecordingBitRate<int>('audioRecordingBitRate', 64000),
|
||||
audioRecordingSamplingRate<int>('audioRecordingSamplingRate', 44100),
|
||||
showNoGoogle<bool>('chat.fluffy.show_no_google', false),
|
||||
unifiedPushRegistered<bool>('chat.fluffy.unifiedpush.registered', false),
|
||||
unifiedPushEndpoint<String>('chat.fluffy.unifiedpush.endpoint', ''),
|
||||
pushNotificationsGatewayUrl<String>(
|
||||
'pushNotificationsGatewayUrl',
|
||||
'https://push.fluffychat.im/_matrix/push/v1/notify',
|
||||
),
|
||||
pushNotificationsPusherFormat<String>(
|
||||
'pushNotificationsPusherFormat',
|
||||
'event_id_only',
|
||||
),
|
||||
renderHtml<bool>('chat.fluffy.renderHtml', true),
|
||||
fontSizeFactor<double>('chat.fluffy.font_size_factor', 1.0),
|
||||
hideRedactedEvents<bool>('chat.fluffy.hideRedactedEvents', false),
|
||||
hideUnknownEvents<bool>('chat.fluffy.hideUnknownEvents', true),
|
||||
autoplayImages<bool>('chat.fluffy.autoplay_images', true),
|
||||
sendTypingNotifications<bool>('chat.fluffy.send_typing_notifications', true),
|
||||
sendPublicReadReceipts<bool>('chat.fluffy.send_public_read_receipts', true),
|
||||
swipeRightToLeftToReply<bool>('chat.fluffy.swipeRightToLeftToReply', true),
|
||||
sendOnEnter<bool>('chat.fluffy.send_on_enter', false),
|
||||
displayNavigationRail<bool>('chat.fluffy.display_navigation_rail', false),
|
||||
shareKeysWith<String>('chat.fluffy.share_keys_with_2', 'all'),
|
||||
noEncryptionWarningShown<bool>(
|
||||
'chat.fluffy.no_encryption_warning_shown',
|
||||
false,
|
||||
),
|
||||
displayChatDetailsColumn('chat.fluffy.display_chat_details_column', false),
|
||||
// AppConfig-mirrored settings
|
||||
applicationName<String>('chat.fluffy.application_name', 'TatuChat'),
|
||||
defaultHomeserver<String>(
|
||||
'chat.fluffy.default_homeserver',
|
||||
'https://matrix.weltonmoura.com.br',
|
||||
),
|
||||
// colorSchemeSeed stored as ARGB int
|
||||
colorSchemeSeedInt<int>('chat.fluffy.color_scheme_seed', 0xFF00D084),
|
||||
emojiSuggestionLocale<String>('emoji_suggestion_locale', ''),
|
||||
enableSoftLogout<bool>('chat.fluffy.enable_soft_logout', true),
|
||||
enableMatrixNativeOIDC<bool>('chat.fluffy.enable_matrix_native_oidc', true),
|
||||
presetHomeserver<String>(
|
||||
'chat.fluffy.preset_homeserver',
|
||||
'https://matrix.weltonmoura.com.br',
|
||||
),
|
||||
welcomeText<String>('chat.fluffy.welcome_text', ''),
|
||||
website<String>('chat.fluffy.website_url', 'https://fluffychat.im'),
|
||||
logoUrl<String>(
|
||||
'chat.fluffy.logo_url',
|
||||
'https://fluffychat.im/assets/favicon.png',
|
||||
),
|
||||
privacyPolicy<String>(
|
||||
'chat.fluffy.privacy_policy_url',
|
||||
'https://fluffychat.im/privacy',
|
||||
),
|
||||
tos<String>('chat.fluffy.tos_url', 'https://fluffychat.im/tos'),
|
||||
sendTimelineEventTimeout<int>('chat.fluffy.send_timeline_event_timeout', 15),
|
||||
webNotificationSound<bool>('chat.fluffy.web_notification_sound', true),
|
||||
chatFilter<String>('chat.fluffy.chat_filter', 'allChats'),
|
||||
hideRoomsInSpaces<bool>('chat.fluffy.hideRoomsInSpaces', false),
|
||||
showThumbnailsInTimeline<bool>('chat.fluffy.showThumbnailsInTimeline', true),
|
||||
doubleTapToReact<bool>('chat.fluffy.double_tap_to_react', false),
|
||||
doubleTapReaction<String>('chat.fluffy.double_tap_reaction', '❤️'),
|
||||
benchmarksInLogs<bool>('chat.fluffy.benchmarks_in_logs', false),
|
||||
autoSendErrorReports<bool?>('chat.fluffy.auto_send_eror_reports', null),
|
||||
knownErrorHashes<List<String>>('chat.fluffy.known_crash_hashes', []),
|
||||
customLiveKitInstance<String>('chat.fluffy.custom_live_kit_instance', '');
|
||||
|
||||
final String key;
|
||||
final T _defaultValue;
|
||||
|
||||
const AppSettings(this.key, this._defaultValue);
|
||||
|
||||
static SharedPreferences get store => _store!;
|
||||
static SharedPreferences? _store;
|
||||
|
||||
static Map<String, Object?>? _platformConfiguration;
|
||||
|
||||
T get defaultValue {
|
||||
final platformConfig = _platformConfiguration?[name];
|
||||
if (platformConfig is T) return platformConfig;
|
||||
return _defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> reset({bool loadWebConfigFile = true}) async {
|
||||
await AppSettings._store!.clear();
|
||||
await init(loadWebConfigFile: loadWebConfigFile);
|
||||
}
|
||||
|
||||
static Future<SharedPreferences> init({bool loadWebConfigFile = true}) async {
|
||||
if (AppSettings._store != null) return AppSettings.store;
|
||||
|
||||
final store = AppSettings._store = await SharedPreferences.getInstance();
|
||||
|
||||
// Migrate wrong datatype for fontSizeFactor
|
||||
final fontSizeFactorString = Result(
|
||||
() => store.getString(AppSettings.fontSizeFactor.key),
|
||||
).asValue?.value;
|
||||
if (fontSizeFactorString != null) {
|
||||
Logs().i('Migrate wrong datatype for fontSizeFactor!');
|
||||
await store.remove(AppSettings.fontSizeFactor.key);
|
||||
final fontSizeFactor = double.tryParse(fontSizeFactorString);
|
||||
if (fontSizeFactor != null) {
|
||||
await store.setDouble(AppSettings.fontSizeFactor.key, fontSizeFactor);
|
||||
}
|
||||
}
|
||||
|
||||
if (store.getBool(AppSettings.sendOnEnter.key) == null) {
|
||||
await store.setBool(AppSettings.sendOnEnter.key, !PlatformInfos.isMobile);
|
||||
}
|
||||
if (store.getBool(AppSettings.doubleTapToReact.key) == null) {
|
||||
await store.setBool(
|
||||
AppSettings.doubleTapToReact.key,
|
||||
PlatformInfos.isMobile,
|
||||
);
|
||||
}
|
||||
|
||||
// Load configuration from config.json file or MDM:
|
||||
if (PlatformInfos.isMobile) {
|
||||
_platformConfiguration =
|
||||
await ManagedConfigurations().getManagedConfigurations;
|
||||
} else if (kIsWeb && loadWebConfigFile) {
|
||||
try {
|
||||
final configJsonString = utf8.decode(
|
||||
(await http.get(Uri.parse('config.json'))).bodyBytes,
|
||||
);
|
||||
_platformConfiguration =
|
||||
json.decode(configJsonString) as Map<String, Object?>;
|
||||
} on FormatException catch (_) {
|
||||
Logs().v('[ConfigLoader] config.json not found');
|
||||
} catch (e) {
|
||||
Logs().v('[ConfigLoader] config.json not found', e);
|
||||
}
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
extension AppSettingsBoolNExtension on AppSettings<bool?> {
|
||||
bool? get value {
|
||||
final value = Result(() => AppSettings.store.getBool(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value;
|
||||
}
|
||||
|
||||
Future<void> setItem(bool value) => AppSettings.store.setBool(key, value);
|
||||
}
|
||||
|
||||
extension AppSettingsBoolExtension on AppSettings<bool> {
|
||||
bool get value {
|
||||
final value = Result(() => AppSettings.store.getBool(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<void> setItem(bool value) => AppSettings.store.setBool(key, value);
|
||||
}
|
||||
|
||||
extension AppSettingsStringExtension on AppSettings<String> {
|
||||
String get value {
|
||||
final value = Result(() => AppSettings.store.getString(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<void> setItem(String value) => AppSettings.store.setString(key, value);
|
||||
}
|
||||
|
||||
extension AppSettingsIntExtension on AppSettings<int> {
|
||||
int get value {
|
||||
final value = Result(() => AppSettings.store.getInt(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<void> setItem(int value) => AppSettings.store.setInt(key, value);
|
||||
}
|
||||
|
||||
extension AppSettingsDoubleExtension on AppSettings<double> {
|
||||
double get value {
|
||||
final value = Result(() => AppSettings.store.getDouble(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<void> setItem(double value) => AppSettings.store.setDouble(key, value);
|
||||
}
|
||||
|
||||
extension AppSettingsStringListExtension on AppSettings<List<String>> {
|
||||
List<String> get value {
|
||||
final value = Result(() => AppSettings.store.getStringList(key));
|
||||
final error = value.asError;
|
||||
if (error != null) {
|
||||
Logs().e(
|
||||
'Unable to fetch $key from storage. Removing entry...',
|
||||
error.error,
|
||||
error.stackTrace,
|
||||
);
|
||||
}
|
||||
return value.asValue?.value ?? defaultValue;
|
||||
}
|
||||
|
||||
Future<void> setItem(List<String> value) =>
|
||||
AppSettings.store.setStringList(key, value);
|
||||
}
|
||||
212
lib/config/tatu_palette.dart
Normal file
212
lib/config/tatu_palette.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// TatuChat brand palette, adapted between light and dark themes.
|
||||
///
|
||||
/// The colors below follow the brand identity:
|
||||
/// * Primary (brand): Emerald Green #00D084
|
||||
/// * Background: Deep Graphite #121519 (dark mode)
|
||||
/// * Surfaces / Cards: Slate Dark #1D222A
|
||||
/// * Accent: Electric Cyan #00E5FF
|
||||
/// * Text primary: Pure White / Ice #F4F6F8
|
||||
/// * Text secondary: Cool Gray #8A94A6
|
||||
/// * Alert / Error: Crimson Rose #FF4D6D
|
||||
abstract class TatuPalette {
|
||||
static const Color brandEmerald = Color(0xFF00D084);
|
||||
static const Color brandGraphite = Color(0xFF121519);
|
||||
static const Color brandSlate = Color(0xFF1D222A);
|
||||
static const Color brandCyan = Color(0xFF00E5FF);
|
||||
static const Color brandIce = Color(0xFFF4F6F8);
|
||||
static const Color brandCoolGray = Color(0xFF8A94A6);
|
||||
static const Color brandCrimson = Color(0xFFFF4D6D);
|
||||
|
||||
static const _Scheme _dark = _Scheme(
|
||||
primary: Color(0xFF00D084),
|
||||
onPrimary: Color(0xFF00281E),
|
||||
primaryContainer: Color(0xFF0E4133),
|
||||
onPrimaryContainer: Color(0xFFBDFFE2),
|
||||
secondary: Color(0xFF8A94A6),
|
||||
onSecondary: Color(0xFF141A24),
|
||||
secondaryContainer: Color(0xFF293240),
|
||||
onSecondaryContainer: Color(0xFFDBE2EE),
|
||||
tertiary: Color(0xFF00E5FF),
|
||||
onTertiary: Color(0xFF003B46),
|
||||
tertiaryContainer: Color(0xFF004A55),
|
||||
onTertiaryContainer: Color(0xFFA6F3FF),
|
||||
error: Color(0xFFFF4D6D),
|
||||
onError: Color(0xFF410013),
|
||||
errorContainer: Color(0xFF7F0024),
|
||||
onErrorContainer: Color(0xFFFFD9DF),
|
||||
surface: Color(0xFF121519),
|
||||
onSurface: Color(0xFFF4F6F8),
|
||||
onSurfaceVariant: Color(0xFF9BA5B6),
|
||||
outline: Color(0xFF6F7B8C),
|
||||
outlineVariant: Color(0xFF323B46),
|
||||
inverseSurface: Color(0xFFE8EBF0),
|
||||
onInverseSurface: Color(0xFF121519),
|
||||
inversePrimary: Color(0xFF008E5F),
|
||||
surfaceLowest: Color(0xFF0C0F13),
|
||||
surfaceLow: Color(0xFF161B21),
|
||||
surfaceContainer: Color(0xFF1D222A),
|
||||
surfaceHigh: Color(0xFF232A34),
|
||||
surfaceHighest: Color(0xFF2A323D),
|
||||
surfaceDim: Color(0xFF121519),
|
||||
surfaceBright: Color(0xFF3A414C),
|
||||
);
|
||||
|
||||
static const _Scheme _light = _Scheme(
|
||||
primary: Color(0xFF00D084),
|
||||
onPrimary: Color(0xFF00291F),
|
||||
primaryContainer: Color(0xFFBDFFE4),
|
||||
onPrimaryContainer: Color(0xFF003A28),
|
||||
secondary: Color(0xFF5F6B7D),
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondaryContainer: Color(0xFFE2E7EF),
|
||||
onSecondaryContainer: Color(0xFF1C2430),
|
||||
tertiary: Color(0xFF00A6C4),
|
||||
onTertiary: Color(0xFF002A33),
|
||||
tertiaryContainer: Color(0xFFB7F1FF),
|
||||
onTertiaryContainer: Color(0xFF00343D),
|
||||
error: Color(0xFFFF4D6D),
|
||||
onError: Color(0xFF430013),
|
||||
errorContainer: Color(0xFFFFDADD),
|
||||
onErrorContainer: Color(0xFF4B0012),
|
||||
surface: Color(0xFFF5F7FA),
|
||||
onSurface: Color(0xFF121519),
|
||||
onSurfaceVariant: Color(0xFF5F6B7D),
|
||||
outline: Color(0xFF7A8595),
|
||||
outlineVariant: Color(0xFFCCD4DD),
|
||||
inverseSurface: Color(0xFF2C3440),
|
||||
onInverseSurface: Color(0xFFF4F6F8),
|
||||
inversePrimary: Color(0xFF2ED9A0),
|
||||
surfaceLowest: Color(0xFFFFFFFF),
|
||||
surfaceLow: Color(0xFFEDF0F4),
|
||||
surfaceContainer: Color(0xFFE8EBF0),
|
||||
surfaceHigh: Color(0xFFE2E6EC),
|
||||
surfaceHighest: Color(0xFFDDE1E7),
|
||||
surfaceDim: Color(0xFFE8ECF1),
|
||||
surfaceBright: Color(0xFFFBFCFE),
|
||||
);
|
||||
|
||||
/// Builds the ColorScheme for [brightness].
|
||||
///
|
||||
/// When a [seed] is provided (e.g. a color chosen by the user or a system
|
||||
/// dynamic color), its tonal values are used for the primary family while
|
||||
/// the brand surfaces, accent and error colors are preserved.
|
||||
static ColorScheme scheme(Brightness brightness, [Color? seed]) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final p = isDark ? _dark : _light;
|
||||
final base = ColorScheme.fromSeed(
|
||||
brightness: brightness,
|
||||
seedColor: seed ?? p.primary,
|
||||
dynamicSchemeVariant: DynamicSchemeVariant.rainbow,
|
||||
);
|
||||
final useBrandPrimary = seed == null;
|
||||
return base.copyWith(
|
||||
primary: useBrandPrimary ? p.primary : null,
|
||||
onPrimary: useBrandPrimary ? p.onPrimary : null,
|
||||
primaryContainer: useBrandPrimary ? p.primaryContainer : null,
|
||||
onPrimaryContainer: useBrandPrimary ? p.onPrimaryContainer : null,
|
||||
surfaceTint: useBrandPrimary ? p.primary : null,
|
||||
secondary: p.secondary,
|
||||
onSecondary: p.onSecondary,
|
||||
secondaryContainer: p.secondaryContainer,
|
||||
onSecondaryContainer: p.onSecondaryContainer,
|
||||
tertiary: p.tertiary,
|
||||
onTertiary: p.onTertiary,
|
||||
tertiaryContainer: p.tertiaryContainer,
|
||||
onTertiaryContainer: p.onTertiaryContainer,
|
||||
error: p.error,
|
||||
onError: p.onError,
|
||||
errorContainer: p.errorContainer,
|
||||
onErrorContainer: p.onErrorContainer,
|
||||
surface: p.surface,
|
||||
onSurface: p.onSurface,
|
||||
onSurfaceVariant: p.onSurfaceVariant,
|
||||
outline: p.outline,
|
||||
outlineVariant: p.outlineVariant,
|
||||
inverseSurface: p.inverseSurface,
|
||||
onInverseSurface: p.onInverseSurface,
|
||||
inversePrimary: p.inversePrimary,
|
||||
surfaceContainerLowest: p.surfaceLowest,
|
||||
surfaceContainerLow: p.surfaceLow,
|
||||
surfaceContainer: p.surfaceContainer,
|
||||
surfaceContainerHigh: p.surfaceHigh,
|
||||
surfaceContainerHighest: p.surfaceHighest,
|
||||
surfaceDim: p.surfaceDim,
|
||||
surfaceBright: p.surfaceBright,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scheme {
|
||||
const _Scheme({
|
||||
required this.primary,
|
||||
required this.onPrimary,
|
||||
required this.primaryContainer,
|
||||
required this.onPrimaryContainer,
|
||||
required this.secondary,
|
||||
required this.onSecondary,
|
||||
required this.secondaryContainer,
|
||||
required this.onSecondaryContainer,
|
||||
required this.tertiary,
|
||||
required this.onTertiary,
|
||||
required this.tertiaryContainer,
|
||||
required this.onTertiaryContainer,
|
||||
required this.error,
|
||||
required this.onError,
|
||||
required this.errorContainer,
|
||||
required this.onErrorContainer,
|
||||
required this.surface,
|
||||
required this.onSurface,
|
||||
required this.onSurfaceVariant,
|
||||
required this.outline,
|
||||
required this.outlineVariant,
|
||||
required this.inverseSurface,
|
||||
required this.onInverseSurface,
|
||||
required this.inversePrimary,
|
||||
required this.surfaceLowest,
|
||||
required this.surfaceLow,
|
||||
required this.surfaceContainer,
|
||||
required this.surfaceHigh,
|
||||
required this.surfaceHighest,
|
||||
required this.surfaceDim,
|
||||
required this.surfaceBright,
|
||||
});
|
||||
|
||||
final Color primary;
|
||||
final Color onPrimary;
|
||||
final Color primaryContainer;
|
||||
final Color onPrimaryContainer;
|
||||
final Color secondary;
|
||||
final Color onSecondary;
|
||||
final Color secondaryContainer;
|
||||
final Color onSecondaryContainer;
|
||||
final Color tertiary;
|
||||
final Color onTertiary;
|
||||
final Color tertiaryContainer;
|
||||
final Color onTertiaryContainer;
|
||||
final Color error;
|
||||
final Color onError;
|
||||
final Color errorContainer;
|
||||
final Color onErrorContainer;
|
||||
final Color surface;
|
||||
final Color onSurface;
|
||||
final Color onSurfaceVariant;
|
||||
final Color outline;
|
||||
final Color outlineVariant;
|
||||
final Color inverseSurface;
|
||||
final Color onInverseSurface;
|
||||
final Color inversePrimary;
|
||||
final Color surfaceLowest;
|
||||
final Color surfaceLow;
|
||||
final Color surfaceContainer;
|
||||
final Color surfaceHigh;
|
||||
final Color surfaceHighest;
|
||||
final Color surfaceDim;
|
||||
final Color surfaceBright;
|
||||
}
|
||||
147
lib/config/themes.dart
Normal file
147
lib/config/themes.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/config/tatu_palette.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
abstract class FluffyThemes {
|
||||
static const double columnWidth = 380.0;
|
||||
|
||||
static const double maxTimelineWidth = columnWidth * 2;
|
||||
|
||||
static const double navRailWidth = 80.0;
|
||||
|
||||
static bool isColumnModeByWidth(double width) =>
|
||||
width > columnWidth * 2 + navRailWidth;
|
||||
|
||||
static bool isColumnMode(BuildContext context) =>
|
||||
isColumnModeByWidth(MediaQuery.sizeOf(context).width);
|
||||
|
||||
static bool isThreeColumnMode(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width > FluffyThemes.columnWidth * 3.5;
|
||||
|
||||
static LinearGradient backgroundGradient(BuildContext context, int alpha) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
colors: [
|
||||
colorScheme.primaryContainer.withAlpha(alpha),
|
||||
colorScheme.secondaryContainer.withAlpha(alpha),
|
||||
colorScheme.tertiaryContainer.withAlpha(alpha),
|
||||
colorScheme.primaryContainer.withAlpha(alpha),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static const Duration animationDuration = Duration(milliseconds: 250);
|
||||
static const Curve animationCurve = Curves.easeInOut;
|
||||
|
||||
static ThemeData buildTheme(
|
||||
BuildContext context,
|
||||
Brightness brightness, [
|
||||
Color? seed,
|
||||
]) {
|
||||
final colorScheme = TatuPalette.scheme(brightness, seed);
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
final dividerColor = brightness == Brightness.dark
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: colorScheme.surfaceContainer;
|
||||
return ThemeData(
|
||||
visualDensity: VisualDensity.standard,
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: colorScheme,
|
||||
dividerColor: dividerColor,
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: SegmentedButton.styleFrom(
|
||||
iconColor: colorScheme.onSurface,
|
||||
disabledIconColor: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
textSelectionTheme: TextSelectionThemeData(
|
||||
selectionColor: colorScheme.onSurface.withAlpha(128),
|
||||
selectionHandleColor: colorScheme.secondary,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
showCheckmark: false,
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
toolbarHeight: isColumnMode ? 72 : 56,
|
||||
surfaceTintColor: isColumnMode ? colorScheme.surface : null,
|
||||
backgroundColor: isColumnMode ? colorScheme.surface : null,
|
||||
actionsPadding: isColumnMode
|
||||
? const EdgeInsets.symmetric(horizontal: 16.0)
|
||||
: null,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: brightness.reversed,
|
||||
statusBarBrightness: brightness,
|
||||
systemNavigationBarIconBrightness: brightness.reversed,
|
||||
systemNavigationBarColor: colorScheme.surface,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(width: 1, color: colorScheme.primary),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||
strokeCap: StrokeCap.round,
|
||||
color: colorScheme.primary,
|
||||
refreshBackgroundColor: colorScheme.primaryContainer,
|
||||
),
|
||||
snackBarTheme: isColumnMode
|
||||
? const SnackBarThemeData(
|
||||
showCloseIcon: true,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: FluffyThemes.columnWidth * 1.5,
|
||||
)
|
||||
: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
foregroundColor: colorScheme.onSecondaryContainer,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.all(16),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on Brightness {
|
||||
Brightness get reversed =>
|
||||
this == Brightness.dark ? Brightness.light : Brightness.dark;
|
||||
}
|
||||
|
||||
extension BubbleColorTheme on ThemeData {
|
||||
Color get bubbleColor => brightness == Brightness.light
|
||||
? colorScheme.primary
|
||||
: colorScheme.primaryContainer;
|
||||
|
||||
Color get onBubbleColor => brightness == Brightness.light
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onPrimaryContainer;
|
||||
|
||||
Color get secondaryBubbleColor => colorScheme.surfaceContainer;
|
||||
}
|
||||
2673
lib/l10n/intl_ar.arb
Normal file
2673
lib/l10n/intl_ar.arb
Normal file
File diff suppressed because it is too large
Load Diff
14
lib/l10n/intl_az.arb
Normal file
14
lib/l10n/intl_az.arb
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"repeatPassword": "Şifrəni təkrarlayın",
|
||||
"notAnImage": "Şəkil faylı deyil.",
|
||||
"remove": "çıxarın",
|
||||
"@remove": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"importNow": "İndi idxal edin",
|
||||
"importEmojis": "Emojiləri idxal edin",
|
||||
"importFromZipFile": ".zip faylından idxal edin",
|
||||
"exportEmotePack": "Emote paketini .zip formatında ixrac edin",
|
||||
"replace": "Əvəz et"
|
||||
}
|
||||
2496
lib/l10n/intl_be.arb
Normal file
2496
lib/l10n/intl_be.arb
Normal file
File diff suppressed because it is too large
Load Diff
1865
lib/l10n/intl_bn.arb
Normal file
1865
lib/l10n/intl_bn.arb
Normal file
File diff suppressed because it is too large
Load Diff
1974
lib/l10n/intl_bo.arb
Normal file
1974
lib/l10n/intl_bo.arb
Normal file
File diff suppressed because it is too large
Load Diff
2504
lib/l10n/intl_ca.arb
Normal file
2504
lib/l10n/intl_ca.arb
Normal file
File diff suppressed because it is too large
Load Diff
2429
lib/l10n/intl_cs.arb
Normal file
2429
lib/l10n/intl_cs.arb
Normal file
File diff suppressed because it is too large
Load Diff
1861
lib/l10n/intl_da.arb
Normal file
1861
lib/l10n/intl_da.arb
Normal file
File diff suppressed because it is too large
Load Diff
2828
lib/l10n/intl_de.arb
Normal file
2828
lib/l10n/intl_de.arb
Normal file
File diff suppressed because it is too large
Load Diff
1880
lib/l10n/intl_el.arb
Normal file
1880
lib/l10n/intl_el.arb
Normal file
File diff suppressed because it is too large
Load Diff
2849
lib/l10n/intl_en.arb
Normal file
2849
lib/l10n/intl_en.arb
Normal file
File diff suppressed because it is too large
Load Diff
2122
lib/l10n/intl_eo.arb
Normal file
2122
lib/l10n/intl_eo.arb
Normal file
File diff suppressed because it is too large
Load Diff
2614
lib/l10n/intl_es.arb
Normal file
2614
lib/l10n/intl_es.arb
Normal file
File diff suppressed because it is too large
Load Diff
2831
lib/l10n/intl_et.arb
Normal file
2831
lib/l10n/intl_et.arb
Normal file
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/intl_eu.arb
Normal file
2579
lib/l10n/intl_eu.arb
Normal file
File diff suppressed because it is too large
Load Diff
2503
lib/l10n/intl_fa.arb
Normal file
2503
lib/l10n/intl_fa.arb
Normal file
File diff suppressed because it is too large
Load Diff
2456
lib/l10n/intl_fi.arb
Normal file
2456
lib/l10n/intl_fi.arb
Normal file
File diff suppressed because it is too large
Load Diff
1997
lib/l10n/intl_fil.arb
Normal file
1997
lib/l10n/intl_fil.arb
Normal file
File diff suppressed because it is too large
Load Diff
2829
lib/l10n/intl_fr.arb
Normal file
2829
lib/l10n/intl_fr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2834
lib/l10n/intl_ga.arb
Normal file
2834
lib/l10n/intl_ga.arb
Normal file
File diff suppressed because it is too large
Load Diff
2788
lib/l10n/intl_gl.arb
Normal file
2788
lib/l10n/intl_gl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2053
lib/l10n/intl_he.arb
Normal file
2053
lib/l10n/intl_he.arb
Normal file
File diff suppressed because it is too large
Load Diff
1849
lib/l10n/intl_hi.arb
Normal file
1849
lib/l10n/intl_hi.arb
Normal file
File diff suppressed because it is too large
Load Diff
2479
lib/l10n/intl_hr.arb
Normal file
2479
lib/l10n/intl_hr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2431
lib/l10n/intl_hu.arb
Normal file
2431
lib/l10n/intl_hu.arb
Normal file
File diff suppressed because it is too large
Load Diff
1992
lib/l10n/intl_ia.arb
Normal file
1992
lib/l10n/intl_ia.arb
Normal file
File diff suppressed because it is too large
Load Diff
2806
lib/l10n/intl_id.arb
Normal file
2806
lib/l10n/intl_id.arb
Normal file
File diff suppressed because it is too large
Load Diff
2013
lib/l10n/intl_ie.arb
Normal file
2013
lib/l10n/intl_ie.arb
Normal file
File diff suppressed because it is too large
Load Diff
2554
lib/l10n/intl_it.arb
Normal file
2554
lib/l10n/intl_it.arb
Normal file
File diff suppressed because it is too large
Load Diff
2130
lib/l10n/intl_ja.arb
Normal file
2130
lib/l10n/intl_ja.arb
Normal file
File diff suppressed because it is too large
Load Diff
1962
lib/l10n/intl_ka.arb
Normal file
1962
lib/l10n/intl_ka.arb
Normal file
File diff suppressed because it is too large
Load Diff
2353
lib/l10n/intl_kab.arb
Normal file
2353
lib/l10n/intl_kab.arb
Normal file
File diff suppressed because it is too large
Load Diff
2398
lib/l10n/intl_ko.arb
Normal file
2398
lib/l10n/intl_ko.arb
Normal file
File diff suppressed because it is too large
Load Diff
138
lib/l10n/intl_la.arb
Normal file
138
lib/l10n/intl_la.arb
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"importFromZipFile": "A tabulario zip importare",
|
||||
"editImage": "Pictura exercere",
|
||||
"@editImage": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"rotateLeft": "Sinistrorsum torquere",
|
||||
"@rotateLeft": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"rotateRight": "Dextrorsum torquere",
|
||||
"@rotateRight": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"save": "Condere",
|
||||
"@save": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"draw": "Pingere",
|
||||
"@draw": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"undo": "Retexere",
|
||||
"@undo": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"clear": "Vacuefacere",
|
||||
"@clear": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"eraser": "Tersus",
|
||||
"@eraser": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"crop": "Secare",
|
||||
"@crop": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"repeatPassword": "Signum repetere",
|
||||
"notAnImage": "Pictura non est.",
|
||||
"remove": "Removere",
|
||||
"@remove": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"importNow": "Nunc importare",
|
||||
"importEmojis": "Emojis importare",
|
||||
"exportEmotePack": "In tabulario zip exportare",
|
||||
"replace": "Compensare",
|
||||
"about": "De",
|
||||
"aboutHomeserver": "De {homeserver}",
|
||||
"@aboutHomeserver": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"homeserver": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accept": "Accipere",
|
||||
"@accept": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"acceptedTheInvitation": "👍 {username} invitationem accepit",
|
||||
"@acceptedTheInvitation": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"username": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": "Ratio",
|
||||
"@account": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"activatedEndToEndEncryption": "🔐 {username} ab origine ad destinatum per notas scribere incepit",
|
||||
"@activatedEndToEndEncryption": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"username": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addEmail": "Email addere",
|
||||
"@addEmail": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"addToSpace": "Ad spatium addere",
|
||||
"admin": "Princeps",
|
||||
"@admin": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"alias": "aliud nomen",
|
||||
"@alias": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"all": "Cuncti",
|
||||
"@all": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"commandHint_cuddle": "Applicatum mittere",
|
||||
"commandHint_hug": "Complexum mittere",
|
||||
"cuddleContent": "{senderName} se ad te applicat",
|
||||
"@cuddleContent": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"senderName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hugContent": "{senderName} te complecitur",
|
||||
"@hugContent": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"senderName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1963
lib/l10n/intl_lt.arb
Normal file
1963
lib/l10n/intl_lt.arb
Normal file
File diff suppressed because it is too large
Load Diff
2807
lib/l10n/intl_lv.arb
Normal file
2807
lib/l10n/intl_lv.arb
Normal file
File diff suppressed because it is too large
Load Diff
2815
lib/l10n/intl_nb.arb
Normal file
2815
lib/l10n/intl_nb.arb
Normal file
File diff suppressed because it is too large
Load Diff
2827
lib/l10n/intl_nl.arb
Normal file
2827
lib/l10n/intl_nl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2711
lib/l10n/intl_pl.arb
Normal file
2711
lib/l10n/intl_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2001
lib/l10n/intl_pt.arb
Normal file
2001
lib/l10n/intl_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
2501
lib/l10n/intl_pt_BR.arb
Normal file
2501
lib/l10n/intl_pt_BR.arb
Normal file
File diff suppressed because it is too large
Load Diff
2204
lib/l10n/intl_pt_PT.arb
Normal file
2204
lib/l10n/intl_pt_PT.arb
Normal file
File diff suppressed because it is too large
Load Diff
2194
lib/l10n/intl_ro.arb
Normal file
2194
lib/l10n/intl_ro.arb
Normal file
File diff suppressed because it is too large
Load Diff
2690
lib/l10n/intl_ru.arb
Normal file
2690
lib/l10n/intl_ru.arb
Normal file
File diff suppressed because it is too large
Load Diff
1221
lib/l10n/intl_sk.arb
Normal file
1221
lib/l10n/intl_sk.arb
Normal file
File diff suppressed because it is too large
Load Diff
1926
lib/l10n/intl_sl.arb
Normal file
1926
lib/l10n/intl_sl.arb
Normal file
File diff suppressed because it is too large
Load Diff
1974
lib/l10n/intl_sq.arb
Normal file
1974
lib/l10n/intl_sq.arb
Normal file
File diff suppressed because it is too large
Load Diff
2107
lib/l10n/intl_sr.arb
Normal file
2107
lib/l10n/intl_sr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2707
lib/l10n/intl_sv.arb
Normal file
2707
lib/l10n/intl_sv.arb
Normal file
File diff suppressed because it is too large
Load Diff
2754
lib/l10n/intl_ta.arb
Normal file
2754
lib/l10n/intl_ta.arb
Normal file
File diff suppressed because it is too large
Load Diff
1977
lib/l10n/intl_te.arb
Normal file
1977
lib/l10n/intl_te.arb
Normal file
File diff suppressed because it is too large
Load Diff
1875
lib/l10n/intl_th.arb
Normal file
1875
lib/l10n/intl_th.arb
Normal file
File diff suppressed because it is too large
Load Diff
2361
lib/l10n/intl_tr.arb
Normal file
2361
lib/l10n/intl_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2552
lib/l10n/intl_uk.arb
Normal file
2552
lib/l10n/intl_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2468
lib/l10n/intl_uz.arb
Normal file
2468
lib/l10n/intl_uz.arb
Normal file
File diff suppressed because it is too large
Load Diff
1958
lib/l10n/intl_vi.arb
Normal file
1958
lib/l10n/intl_vi.arb
Normal file
File diff suppressed because it is too large
Load Diff
1889
lib/l10n/intl_yue_Hant.arb
Normal file
1889
lib/l10n/intl_yue_Hant.arb
Normal file
File diff suppressed because it is too large
Load Diff
2828
lib/l10n/intl_zh.arb
Normal file
2828
lib/l10n/intl_zh.arb
Normal file
File diff suppressed because it is too large
Load Diff
2428
lib/l10n/intl_zh_Hant.arb
Normal file
2428
lib/l10n/intl_zh_Hant.arb
Normal file
File diff suppressed because it is too large
Load Diff
180
lib/main.dart
Normal file
180
lib/main.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/utils/client_manager.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/notification_background_handler.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/utils/start_push_foreground_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
||||
import 'package:just_audio_media_kit/just_audio_media_kit.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:universal_html/universal_html.dart' as web;
|
||||
|
||||
import 'config/setting_keys.dart';
|
||||
import 'utils/background_push.dart';
|
||||
import 'widgets/tatu_chat_app.dart';
|
||||
|
||||
ReceivePort? mainIsolateReceivePort;
|
||||
|
||||
bool _vodozemacInitialized = false;
|
||||
|
||||
bool isIntegrationTest = false;
|
||||
|
||||
void main(List<String> args) => runZonedGuarded(() async {
|
||||
// Forward Flutter errors to global error reporter
|
||||
FlutterError.onError = (details) => Zone.current.handleUncaughtError(
|
||||
details.exception,
|
||||
details.stack ?? StackTrace.current,
|
||||
);
|
||||
|
||||
isIntegrationTest = args.singleOrNull == 'integration_test';
|
||||
if (PlatformInfos.isAndroid) {
|
||||
final port = mainIsolateReceivePort = ReceivePort();
|
||||
IsolateNameServer.removePortNameMapping(AppConfig.mainIsolatePortName);
|
||||
IsolateNameServer.registerPortWithName(
|
||||
port.sendPort,
|
||||
AppConfig.mainIsolatePortName,
|
||||
);
|
||||
await waitForPushIsolateDone();
|
||||
}
|
||||
|
||||
// Sanitize hash for OIDC:
|
||||
if (kIsWeb) {
|
||||
final hash = web.window.location.hash;
|
||||
if (hash.isNotEmpty && !hash.startsWith('/')) {
|
||||
web.window.location.hash = hash.replaceFirst('#', '#?');
|
||||
}
|
||||
}
|
||||
|
||||
// Our background push shared isolate accesses flutter-internal things very early in the startup proccess
|
||||
// To make sure that the parts of flutter needed are started up already, we need to ensure that the
|
||||
// widget bindings are initialized already.
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final store = await AppSettings.init();
|
||||
Logs().i('Welcome to ${AppSettings.applicationName.value} <3');
|
||||
|
||||
kEnableMatrixSdkBenchmarks = AppSettings.benchmarksInLogs.value;
|
||||
|
||||
if (!_vodozemacInitialized) {
|
||||
await vod.init(
|
||||
wasmPath: './assets/assets/vodozemac/${AppConfig.vodozemacVersion}/',
|
||||
);
|
||||
_vodozemacInitialized = true;
|
||||
}
|
||||
|
||||
Logs().nativeColors = !PlatformInfos.isIOS;
|
||||
|
||||
// If the app starts in detached mode, we assume that it is in
|
||||
// background fetch mode for processing push notifications. This is
|
||||
// currently only supported on Android.
|
||||
if (PlatformInfos.isAndroid &&
|
||||
AppLifecycleState.detached == WidgetsBinding.instance.lifecycleState) {
|
||||
await ForegroundServices.startService('background_push');
|
||||
|
||||
final clients = await ClientManager.getClients(store: store);
|
||||
|
||||
// Do not send online presences when app is in background fetch mode.
|
||||
for (final client in clients) {
|
||||
client.backgroundSync = false;
|
||||
client.syncPresence = PresenceType.offline;
|
||||
}
|
||||
|
||||
// In the background fetch mode we do not want to waste ressources with
|
||||
// starting the Flutter engine but process incoming push notifications.
|
||||
BackgroundPush.clientOnly(clients);
|
||||
// To start the flutter engine afterwards we add an custom observer.
|
||||
WidgetsBinding.instance.addObserver(AppStarter(clients, store));
|
||||
Logs().i(
|
||||
'${AppSettings.applicationName.value} started in background-fetch mode. No GUI will be created unless the app is no longer detached.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final clients = await ClientManager.getClients(store: store);
|
||||
|
||||
// Started in foreground mode.
|
||||
Logs().i(
|
||||
'${AppSettings.applicationName.value} started in foreground mode. Rendering GUI...',
|
||||
);
|
||||
await startGui(clients, store);
|
||||
}, ErrorReporter.onFlutterError);
|
||||
|
||||
/// Fetch the pincode for the applock and start the flutter engine.
|
||||
Future<void> startGui(List<Client> clients, SharedPreferences store) async {
|
||||
// Fetch the pin for the applock if existing for mobile applications.
|
||||
String? pin;
|
||||
var useBiometrics = false;
|
||||
if (PlatformInfos.supportsAppLock) {
|
||||
try {
|
||||
pin = await const FlutterSecureStorage().read(
|
||||
key: 'chat.fluffy.app_lock',
|
||||
);
|
||||
useBiometrics =
|
||||
(await const FlutterSecureStorage().read(
|
||||
key: 'chat.fluffy.use_biometrics',
|
||||
)) ==
|
||||
'true';
|
||||
} catch (e, s) {
|
||||
Logs().d('Unable to read PIN from Secure storage', e, s);
|
||||
}
|
||||
}
|
||||
|
||||
if (PlatformInfos.isLinux || PlatformInfos.isWindows) {
|
||||
JustAudioMediaKit.ensureInitialized();
|
||||
}
|
||||
|
||||
// Preload first client
|
||||
final firstClient = clients.firstOrNull;
|
||||
await firstClient?.roomsLoading;
|
||||
await firstClient?.accountDataLoading;
|
||||
|
||||
runApp(
|
||||
TatuChatApp(
|
||||
clients: clients,
|
||||
appLockSettings: (pincode: pin, useBiometrics: useBiometrics),
|
||||
store: store,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Watches the lifecycle changes to start the application when it
|
||||
/// is no longer detached.
|
||||
class AppStarter with WidgetsBindingObserver {
|
||||
final List<Client> clients;
|
||||
final SharedPreferences store;
|
||||
bool guiStarted = false;
|
||||
|
||||
AppStarter(this.clients, this.store);
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (guiStarted) return;
|
||||
if (state == AppLifecycleState.detached) return;
|
||||
|
||||
Logs().i(
|
||||
'${AppSettings.applicationName.value} switches from the detached background-fetch mode to ${state.name} mode. Rendering GUI...',
|
||||
);
|
||||
// Switching to foreground mode needs to reenable send online sync presence.
|
||||
for (final client in clients) {
|
||||
client.backgroundSync = true;
|
||||
client.syncPresence = PresenceType.online;
|
||||
}
|
||||
startGui(clients, store);
|
||||
// We must make sure that the GUI is only started once.
|
||||
guiStarted = true;
|
||||
}
|
||||
}
|
||||
75
lib/pages/archive/archive.dart
Normal file
75
lib/pages/archive/archive.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/archive/archive_view.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class Archive extends StatefulWidget {
|
||||
const Archive({super.key});
|
||||
|
||||
@override
|
||||
ArchiveController createState() => ArchiveController();
|
||||
}
|
||||
|
||||
class ArchiveController extends State<Archive> {
|
||||
List<ArchivedRoom> archive = [];
|
||||
|
||||
Future<List<ArchivedRoom>> getArchive(BuildContext context) async {
|
||||
if (archive.isNotEmpty) return archive;
|
||||
return archive = await Matrix.of(context).client.loadArchiveWithTimeline();
|
||||
}
|
||||
|
||||
Future<void> forgetRoomAction(int i) async {
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
Logs().v('Forget room ${archive.last.room.getLocalizedDisplayname()}');
|
||||
await archive[i].room.forget();
|
||||
archive.removeAt(i);
|
||||
},
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> forgetAllAction() async {
|
||||
final archive = this.archive;
|
||||
if (archive.isEmpty) return;
|
||||
if (await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context).areYouSure,
|
||||
okLabel: L10n.of(context).yes,
|
||||
cancelLabel: L10n.of(context).cancel,
|
||||
message: L10n.of(context).clearArchive,
|
||||
) !=
|
||||
OkCancelResult.ok) {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
futureWithProgress: (onProgress) async {
|
||||
final count = archive.length;
|
||||
while (archive.isNotEmpty) {
|
||||
onProgress(1 - (archive.length / count));
|
||||
Logs().v(
|
||||
'Forget room ${archive.last.room.getLocalizedDisplayname()}',
|
||||
);
|
||||
await archive.last.room.forget();
|
||||
archive.removeLast();
|
||||
}
|
||||
},
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ArchiveView(this);
|
||||
}
|
||||
79
lib/pages/archive/archive_view.dart
Normal file
79
lib/pages/archive/archive_view.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/archive/archive.dart';
|
||||
import 'package:tatchat/pages/chat_list/chat_list_item.dart';
|
||||
import 'package:tatchat/widgets/layouts/max_width_body.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class ArchiveView extends StatelessWidget {
|
||||
final ArchiveController controller;
|
||||
|
||||
const ArchiveView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<ArchivedRoom>>(
|
||||
future: controller.getArchive(context),
|
||||
builder: (BuildContext context, snapshot) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const Center(child: BackButton()),
|
||||
title: Text(L10n.of(context).archive),
|
||||
actions: [
|
||||
if (snapshot.data?.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextButton.icon(
|
||||
onPressed: controller.forgetAllAction,
|
||||
label: Text(L10n.of(context).clearArchive),
|
||||
icon: const Icon(Icons.cleaning_services_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: MaxWidthBody(
|
||||
withScrolling: false,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
L10n.of(context).oopsSomethingWentWrong,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
);
|
||||
} else {
|
||||
if (controller.archive.isEmpty) {
|
||||
return const Center(
|
||||
child: Icon(Icons.archive_outlined, size: 80),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: controller.archive.length,
|
||||
itemBuilder: (BuildContext context, int i) => ChatListItem(
|
||||
controller.archive[i].room,
|
||||
onForget: () => controller.forgetRoomAction(i),
|
||||
onTap: () => context.go(
|
||||
'/rooms/archive/${controller.archive[i].room.id}',
|
||||
extra: controller.archive[i].timeline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/pages/bootstrap/bootstrap_page.dart
Normal file
96
lib/pages/bootstrap/bootstrap_page.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/new_passphrase_view.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/restore_bootstrap_view.dart';
|
||||
import 'package:tatchat/pages/bootstrap/widgets/store_recovery_key_view.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:tatchat/widgets/layouts/login_scaffold.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/view_model_builder.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class BootstrapPage extends StatelessWidget {
|
||||
final bool reset;
|
||||
const BootstrapPage({required this.reset, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return ViewModelBuilder(
|
||||
create: () =>
|
||||
BootstrapViewModel(client: Matrix.of(context).client, reset: reset),
|
||||
builder: (context, viewModel, _) {
|
||||
final cryptoIdentityState = viewModel.value.cryptoIdentityState;
|
||||
if (cryptoIdentityState?.connected == true && !reset) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => viewModel.goToRoomsPageAfterSuccess(context),
|
||||
);
|
||||
}
|
||||
|
||||
final title = cryptoIdentityState == null
|
||||
? L10n.of(context).loadingPleaseWait
|
||||
: cryptoIdentityState.initialized && !viewModel.value.reset
|
||||
? L10n.of(context).restoreCryptoIdentity
|
||||
: viewModel.value.reset
|
||||
? viewModel.value.recoveryKey != null
|
||||
? L10n.of(context).youAreReadyToStart
|
||||
: L10n.of(context).resetCryptoIdentity
|
||||
: L10n.of(context).setUpCryptoIdentity;
|
||||
|
||||
return LoginScaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: viewModel.value.recoveryKey != null
|
||||
? null
|
||||
: CloseButton(
|
||||
onPressed: () async {
|
||||
if (!reset) {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).skipChatBackup,
|
||||
message: L10n.of(context).skipChatBackupWarning,
|
||||
okLabel: L10n.of(context).skip,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
}
|
||||
context.go('/rooms');
|
||||
},
|
||||
),
|
||||
title: Text(title),
|
||||
actions: [
|
||||
if (viewModel.value.recoveryKey != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
onPressed: () => context.go('/rooms'),
|
||||
child: Text(L10n.of(context).continueText),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
body: cryptoIdentityState == null
|
||||
? Center(child: CircularProgressIndicator.adaptive())
|
||||
: !cryptoIdentityState.initialized || viewModel.value.reset
|
||||
? viewModel.value.recoveryKey == null
|
||||
? NewPassphraseView(viewModel)
|
||||
: StoreRecoveryKeyView(viewModel)
|
||||
: RestoreBootstrapView(viewModel),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
35
lib/pages/bootstrap/view_model/bootstrap_state.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class BootstrapViewModelState {
|
||||
String? recoveryKey;
|
||||
bool isLoading = false;
|
||||
Object? unlockWithError;
|
||||
({
|
||||
bool connected,
|
||||
bool crossSigningEnabled,
|
||||
bool initialized,
|
||||
bool keyBackupEnabled,
|
||||
})?
|
||||
cryptoIdentityState;
|
||||
bool reset = false;
|
||||
KeyVerification? keyVerification;
|
||||
List<DeviceKeys>? connectedDevices;
|
||||
bool obscureText = true;
|
||||
bool waitingForSecrets = false;
|
||||
bool noSecretsreceived = false;
|
||||
|
||||
bool newPassphraseEqualsRepeatPassphrase = false;
|
||||
bool newPassphraseLongEnough = false;
|
||||
bool newPassphraseUpperAndLowerCase = false;
|
||||
bool newPassphraseSpecialCharacters = false;
|
||||
bool newPassphraseNumbers = false;
|
||||
bool passphraseOrRecoveryKeyEntered = false;
|
||||
bool recoveryKeyDownloaded = false;
|
||||
bool recoveryKeyStoredInSecureStorage = false;
|
||||
}
|
||||
302
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
302
lib/pages/bootstrap/view_model/bootstrap_view_model.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/encryption.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'bootstrap_state.dart';
|
||||
|
||||
class BootstrapViewModel extends ValueNotifier<BootstrapViewModelState> {
|
||||
final Client client;
|
||||
final bool reset;
|
||||
|
||||
final TextEditingController enterPassphraseOrRecovController =
|
||||
TextEditingController();
|
||||
final TextEditingController newPassphraseController = TextEditingController();
|
||||
final TextEditingController repeatPassphraseController =
|
||||
TextEditingController();
|
||||
final ScrollController devicesScrollController = ScrollController();
|
||||
|
||||
BootstrapViewModel({required this.client, required this.reset})
|
||||
: super(BootstrapViewModelState()..reset = reset) {
|
||||
_init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelKeyVerification();
|
||||
enterPassphraseOrRecovController.dispose();
|
||||
newPassphraseController.dispose();
|
||||
repeatPassphraseController.dispose();
|
||||
devicesScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _checkCanCreatePassphrase([_]) {
|
||||
final passphrase = newPassphraseController.text;
|
||||
value.newPassphraseEqualsRepeatPassphrase =
|
||||
passphrase.isNotEmpty && passphrase == repeatPassphraseController.text;
|
||||
value.newPassphraseLongEnough = passphrase.length >= 12;
|
||||
value.newPassphraseUpperAndLowerCase =
|
||||
passphrase.contains(RegExp(r'[A-Z]')) &&
|
||||
passphrase.contains(RegExp(r'[a-z]'));
|
||||
value.newPassphraseSpecialCharacters = passphrase.contains(
|
||||
RegExp(r'[!@#$%^&*(),.?":{}|<>]'),
|
||||
);
|
||||
value.newPassphraseNumbers = passphrase.contains(RegExp(r'\d'));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> retryKeyVerification() async {
|
||||
value.noSecretsreceived = false;
|
||||
value.keyVerification = await client.userDeviceKeys[client.userID!]!
|
||||
.startVerification();
|
||||
value.keyVerification?.onUpdate = _onKeyVerificationUpdate;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final state = value.cryptoIdentityState = await client
|
||||
.getCryptoIdentityState();
|
||||
newPassphraseController.addListener(_checkCanCreatePassphrase);
|
||||
repeatPassphraseController.addListener(_checkCanCreatePassphrase);
|
||||
enterPassphraseOrRecovController.addListener(
|
||||
_passphraseOrRecoveryKeyEntered,
|
||||
);
|
||||
if (state.initialized) {
|
||||
if (state.connected) return notifyListeners();
|
||||
|
||||
await client.updateUserDeviceKeys();
|
||||
|
||||
final devices = value.connectedDevices =
|
||||
client.userDeviceKeys[client.userID!]?.deviceKeys.values
|
||||
.where(
|
||||
(device) => device.hasValidSignatureChain(
|
||||
verifiedByTheirMasterKey: true,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
if (devices.isNotEmpty) {
|
||||
value.keyVerification = await client.userDeviceKeys[client.userID!]!
|
||||
.startVerification();
|
||||
value.keyVerification?.onUpdate = _onKeyVerificationUpdate;
|
||||
}
|
||||
if (supportsSecureStorage) {
|
||||
try {
|
||||
final keyFromSecureStorage = await FlutterSecureStorage().read(
|
||||
key: _secureStorageKey,
|
||||
);
|
||||
if (keyFromSecureStorage != null) {
|
||||
enterPassphraseOrRecovController.text = keyFromSecureStorage;
|
||||
}
|
||||
} catch (e, s) {
|
||||
Logs().e('Unable to read key from secure storage', e, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _passphraseOrRecoveryKeyEntered() {
|
||||
final passphraseOrRecoveryKeyEntered =
|
||||
enterPassphraseOrRecovController.text.isNotEmpty;
|
||||
if (value.passphraseOrRecoveryKeyEntered !=
|
||||
passphraseOrRecoveryKeyEntered) {
|
||||
value.passphraseOrRecoveryKeyEntered = passphraseOrRecoveryKeyEntered;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onKeyVerificationUpdate() async {
|
||||
if (value.keyVerification?.state == KeyVerificationState.done) {
|
||||
value.waitingForSecrets = true;
|
||||
value.noSecretsreceived = false;
|
||||
notifyListeners();
|
||||
value.cryptoIdentityState = await client.getCryptoIdentityState();
|
||||
var tries = 0;
|
||||
const max = 10;
|
||||
while (value.cryptoIdentityState?.connected != true) {
|
||||
Logs().d('Waiting for secrets... [$tries/$max]');
|
||||
if (tries >= max) return;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
value.cryptoIdentityState = await client.getCryptoIdentityState();
|
||||
tries++;
|
||||
}
|
||||
|
||||
if (value.cryptoIdentityState?.connected != true) {
|
||||
value.waitingForSecrets = false;
|
||||
value.noSecretsreceived = true;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setOrSkipPassphrase(
|
||||
String? passphrase,
|
||||
BuildContext context,
|
||||
) async {
|
||||
value.isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
value.recoveryKey = await client.initCryptoIdentity(
|
||||
passphrase: passphrase,
|
||||
wipeCrossSigning: !reset,
|
||||
wipeKeyBackup: !reset,
|
||||
wipeSecureStorage: !reset,
|
||||
setupOnlineKeyBackup: !reset,
|
||||
setupMasterKey: !reset,
|
||||
setupSelfSigningKey: !reset,
|
||||
setupUserSigningKey: !reset,
|
||||
);
|
||||
} catch (e, s) {
|
||||
if (!context.mounted) return;
|
||||
ErrorReporter(
|
||||
context,
|
||||
'Unable to init crypto identity',
|
||||
).onErrorCallback(e, s);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
value.isLoading = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _cancelKeyVerification() {
|
||||
final keyVerification = value.keyVerification;
|
||||
if (keyVerification != null &&
|
||||
keyVerification.state != KeyVerificationState.done &&
|
||||
keyVerification.state != KeyVerificationState.error) {
|
||||
keyVerification.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
bool get supportsSecureStorage =>
|
||||
PlatformInfos.isMobile || PlatformInfos.isDesktop;
|
||||
|
||||
Future<void> unlock(BuildContext context) async {
|
||||
final key = enterPassphraseOrRecovController.text.trim();
|
||||
if (key.isEmpty) return;
|
||||
|
||||
_cancelKeyVerification();
|
||||
|
||||
value.unlockWithError = null;
|
||||
value.isLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await client.restoreCryptoIdentity(key);
|
||||
value.isLoading = false;
|
||||
value.cryptoIdentityState = await client.getCryptoIdentityState();
|
||||
notifyListeners();
|
||||
return;
|
||||
} catch (e, s) {
|
||||
if (e is! InvalidPassphraseException) {
|
||||
const errorMessage = 'Unexpected error on unlock passphrase';
|
||||
if (context.mounted) {
|
||||
ErrorReporter(context, errorMessage).onErrorCallback(e, s);
|
||||
} else {
|
||||
Logs().wtf(errorMessage, e, s);
|
||||
}
|
||||
}
|
||||
value.isLoading = false;
|
||||
value.unlockWithError = e;
|
||||
notifyListeners();
|
||||
if (supportsSecureStorage) {
|
||||
await FlutterSecureStorage().delete(key: _secureStorageKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void goToRoomsPageAfterSuccess(BuildContext context) {
|
||||
for (final room in client.rooms) {
|
||||
final lastEvent = room.lastEvent;
|
||||
if (lastEvent == null ||
|
||||
lastEvent.messageType != MessageTypes.BadEncrypted ||
|
||||
lastEvent.content['can_request_session'] != true) {
|
||||
continue;
|
||||
}
|
||||
final sessionId = lastEvent.content.tryGet<String>('session_id');
|
||||
final senderKey = lastEvent.content.tryGet<String>('sender_key');
|
||||
if (sessionId != null && senderKey != null) {
|
||||
client.encryption?.keyManager.maybeAutoRequest(
|
||||
room.id,
|
||||
sessionId,
|
||||
senderKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
context.go('/rooms');
|
||||
}
|
||||
|
||||
void toggleObscureText() {
|
||||
value.obscureText = !value.obscureText;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startResetAccount() {
|
||||
value.reset = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String get _secureStorageKey => 'ssss_recovery_key_${client.userID}';
|
||||
|
||||
Future<void> openRecoveryKeyFile(BuildContext context) async {
|
||||
final result = await FilePicker.pickFile(
|
||||
allowedExtensions: ['txt'],
|
||||
type: FileType.custom,
|
||||
);
|
||||
final file = result?.xFile;
|
||||
if (file == null) return;
|
||||
try {
|
||||
final key = await file.readAsString();
|
||||
enterPassphraseOrRecovController.text = key;
|
||||
} catch (e, s) {
|
||||
Logs().d('Unable to read recovery key file', e, s);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
}
|
||||
}
|
||||
if (context.mounted) await unlock(context);
|
||||
}
|
||||
|
||||
Future<void> toggleRecoveryKeyDownloaded(
|
||||
bool? downloaded,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final path = await FilePicker.saveFile(
|
||||
fileName:
|
||||
'FluffyChat-Recovery-Key-${DateTime.now().toIso8601String()}.txt',
|
||||
bytes: Uint8List.fromList(value.recoveryKey!.codeUnits),
|
||||
);
|
||||
if (path == null) return;
|
||||
value.recoveryKeyDownloaded = downloaded == true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleRecoveryKeyStoredInSecureStorage(bool? stored) async {
|
||||
if (stored == true) {
|
||||
await FlutterSecureStorage().write(
|
||||
key: _secureStorageKey,
|
||||
value: value.recoveryKey,
|
||||
);
|
||||
} else {
|
||||
await FlutterSecureStorage().delete(key: _secureStorageKey);
|
||||
}
|
||||
value.recoveryKeyStoredInSecureStorage = stored == true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
129
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
129
lib/pages/bootstrap/widgets/new_passphrase_view.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class NewPassphraseView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
|
||||
const NewPassphraseView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canCreatePassphrase =
|
||||
viewModel.value.newPassphraseEqualsRepeatPassphrase &&
|
||||
viewModel.value.newPassphraseNumbers &&
|
||||
viewModel.value.newPassphraseSpecialCharacters &&
|
||||
viewModel.value.newPassphraseUpperAndLowerCase &&
|
||||
viewModel.value.newPassphraseLongEnough;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Text(L10n.of(context).newPassphraseDescription, textAlign: .center),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
obscureText: viewModel.value.obscureText,
|
||||
readOnly: viewModel.value.isLoading,
|
||||
controller: viewModel.newPassphraseController,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
viewModel.value.obscureText
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
onPressed: viewModel.toggleObscureText,
|
||||
),
|
||||
hintText: L10n.of(context).newPassphrase,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
obscureText: viewModel.value.obscureText,
|
||||
readOnly: viewModel.value.isLoading,
|
||||
controller: viewModel.repeatPassphraseController,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).repeatPassphrase,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: canCreatePassphrase && !viewModel.value.isLoading
|
||||
? () => viewModel.setOrSkipPassphrase(
|
||||
viewModel.newPassphraseController.text,
|
||||
context,
|
||||
)
|
||||
: null,
|
||||
child: viewModel.value.isLoading
|
||||
? CircularProgressIndicator.adaptive()
|
||||
: Text(L10n.of(context).continueText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: () => viewModel.setOrSkipPassphrase(null, context),
|
||||
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
child: Text(L10n.of(context).skip),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseEqualsRepeatPassphrase,
|
||||
label: L10n.of(context).passphrasesMatch,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseLongEnough,
|
||||
label: L10n.of(context).passphraseLengthRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseUpperAndLowerCase,
|
||||
label: L10n.of(context).passphraseUpperAndLowerCaseRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseSpecialCharacters,
|
||||
label: L10n.of(context).passphraseSpecialCharactersRequirement,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PassphraseCheckListTile(
|
||||
checked: viewModel.value.newPassphraseNumbers,
|
||||
label: L10n.of(context).passphraseNumberRequirement,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PassphraseCheckListTile extends StatelessWidget {
|
||||
final String label;
|
||||
final bool checked;
|
||||
const _PassphraseCheckListTile({required this.label, required this.checked});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
Icon(
|
||||
checked ? Icons.check_circle_outlined : Icons.circle_outlined,
|
||||
color: checked
|
||||
? theme.brightness == Brightness.light
|
||||
? Colors.green.shade800
|
||||
: Colors.green.shade300
|
||||
: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
Text(label, style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
198
lib/pages/bootstrap/widgets/restore_bootstrap_view.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/pages/key_verification/key_verification_dialog.dart';
|
||||
import 'package:tatchat/utils/date_time_extension.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/device_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/encryption/utils/key_verification.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class RestoreBootstrapView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
|
||||
const RestoreBootstrapView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final keyVerification = viewModel.value.keyVerification;
|
||||
if (keyVerification != null) {
|
||||
Logs().v('Key verification state:', keyVerification.state);
|
||||
if (keyVerification.state == KeyVerificationState.askSas) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
KeyVerificationDialog(request: keyVerification).show(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
final devices =
|
||||
viewModel.value.connectedDevices
|
||||
?.map(
|
||||
(device) => (
|
||||
title: device.displayname,
|
||||
lastActive: device.lastActive,
|
||||
icon: device.icon,
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
if (keyVerification != null) ...[
|
||||
ListTile(
|
||||
leading:
|
||||
(keyVerification.state == KeyVerificationState.error ||
|
||||
viewModel.value.noSecretsreceived)
|
||||
? IconButton(
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: viewModel.retryKeyVerification,
|
||||
tooltip: L10n.of(context).tryAgain,
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
minLeadingWidth: 40,
|
||||
title: Text(
|
||||
viewModel.value.waitingForSecrets
|
||||
? L10n.of(context).waitingForKeys
|
||||
: viewModel.value.noSecretsreceived
|
||||
? L10n.of(context).noKeysTransmitted
|
||||
: L10n.of(context).restoreBootstrapDevicesDescription,
|
||||
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Material(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 128),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
controller: viewModel.devicesScrollController,
|
||||
child: ListView.builder(
|
||||
controller: viewModel.devicesScrollController,
|
||||
shrinkWrap: true,
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, i) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
child: Icon(devices[i].icon),
|
||||
),
|
||||
title: Text(
|
||||
devices[i].title,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
L10n.of(context).lastActiveAgo(
|
||||
devices[i].lastActive.localizedTime(context),
|
||||
),
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(height: 64)),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(L10n.of(context).or),
|
||||
),
|
||||
Expanded(child: Divider(height: 64)),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
L10n.of(context).restoreBootstrapEmptyDevicesDescription,
|
||||
textAlign: .center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
TextField(
|
||||
readOnly: viewModel.value.isLoading,
|
||||
obscureText: viewModel.value.obscureText,
|
||||
controller: viewModel.enterPassphraseOrRecovController,
|
||||
minLines: 1,
|
||||
maxLines: viewModel.value.obscureText ? 1 : 4,
|
||||
onSubmitted: (_) => viewModel.unlock(context),
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).passphraseOrKey,
|
||||
prefixIcon: IconButton(
|
||||
icon: Icon(
|
||||
viewModel.value.obscureText
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
onPressed: viewModel.toggleObscureText,
|
||||
),
|
||||
errorText: viewModel.value.unlockWithError?.toLocalizedString(
|
||||
context,
|
||||
),
|
||||
errorMaxLines: 4,
|
||||
suffixIcon: viewModel.value.isLoading
|
||||
? SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: viewModel.value.passphraseOrRecoveryKeyEntered
|
||||
? () => viewModel.unlock(context)
|
||||
: () => viewModel.openRecoveryKeyFile(context),
|
||||
child: Text(
|
||||
viewModel.value.passphraseOrRecoveryKeyEntered
|
||||
? L10n.of(context).unlock
|
||||
: L10n.of(context).openFile,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final consent = await showOkCancelAlertDialog(
|
||||
context: context,
|
||||
title: L10n.of(context).warning,
|
||||
message: L10n.of(context).resetAccountWarning,
|
||||
isDestructive: true,
|
||||
okLabel: L10n.of(context).resetAccount,
|
||||
);
|
||||
if (consent != OkCancelResult.ok) return;
|
||||
if (!context.mounted) return;
|
||||
viewModel.startResetAccount();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
child: Text(L10n.of(context).resetAccount),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
63
lib/pages/bootstrap/widgets/store_recovery_key_view.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/bootstrap/view_model/bootstrap_view_model.dart';
|
||||
import 'package:tatchat/utils/tatu_share.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class StoreRecoveryKeyView extends StatelessWidget {
|
||||
final BootstrapViewModel viewModel;
|
||||
const StoreRecoveryKeyView(this.viewModel, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
Text(L10n.of(context).storeRecoveryKeyDescription),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
// TODO: Dispose this
|
||||
controller: TextEditingController(text: viewModel.value.recoveryKey),
|
||||
readOnly: true,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.copy_outlined),
|
||||
onPressed: () =>
|
||||
TatuShare.share(viewModel.value.recoveryKey!, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile.adaptive(
|
||||
value: viewModel.value.recoveryKeyDownloaded,
|
||||
onChanged: (copied) =>
|
||||
viewModel.toggleRecoveryKeyDownloaded(copied, context),
|
||||
title: Text(L10n.of(context).saveAsFile),
|
||||
),
|
||||
if (viewModel.supportsSecureStorage)
|
||||
CheckboxListTile.adaptive(
|
||||
value: viewModel.value.recoveryKeyStoredInSecureStorage,
|
||||
onChanged: viewModel.toggleRecoveryKeyStoredInSecureStorage,
|
||||
title: Text(_getSecureStorageLocalizedName(context)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getSecureStorageLocalizedName(BuildContext context) {
|
||||
if (PlatformInfos.isAndroid) {
|
||||
return L10n.of(context).storeInAndroidKeystore;
|
||||
}
|
||||
if (PlatformInfos.isIOS || PlatformInfos.isMacOS) {
|
||||
return L10n.of(context).storeInAppleKeyChain;
|
||||
}
|
||||
return L10n.of(context).storeSecurlyOnThisDevice;
|
||||
}
|
||||
}
|
||||
537
lib/pages/call/call_page.dart
Normal file
537
lib/pages/call/call_page.dart
Normal file
@@ -0,0 +1,537 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/call/call_tile.dart';
|
||||
import 'package:tatchat/pages/call/call_view_model.dart';
|
||||
import 'package:tatchat/pages/call/start_time.dart';
|
||||
import 'package:tatchat/pages/call/utils/get_call_tiles.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/utils/platform_infos.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/view_model_builder.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class CallPage extends StatelessWidget {
|
||||
final String roomId;
|
||||
const CallPage({super.key, required this.roomId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final room = Matrix.of(context).client.getRoomById(roomId)!;
|
||||
final mini = Matrix.of(context).callPosition.value != .fullScreen;
|
||||
final tileSize = FluffyThemes.isColumnMode(context) ? 256.0 : 128.0;
|
||||
return ViewModelBuilder(
|
||||
create: () => CallViewModel(room: room),
|
||||
builder: (context, viewModel, _) {
|
||||
final liveKitRoom = viewModel.value.room;
|
||||
final localVideoTrack = viewModel.value.localVideoTrack;
|
||||
final localParticipant = liveKitRoom?.localParticipant;
|
||||
|
||||
final activeMembers = room.getActiveMatrixRtcMembers().length;
|
||||
final ownUser = room.unsafeGetUserFromMemoryOrFallback(
|
||||
room.client.userID!,
|
||||
);
|
||||
final ownHandRaised = viewModel.participantRaisedHand(ownUser.id);
|
||||
final userIsScreensharing =
|
||||
localParticipant?.isScreenShareEnabled() == true;
|
||||
final iconButtonStyle = IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.surfaceBright.withAlpha(230),
|
||||
disabledBackgroundColor: theme.colorScheme.surfaceBright.withAlpha(
|
||||
230,
|
||||
),
|
||||
iconSize: mini ? null : 28,
|
||||
padding: EdgeInsets.all(mini ? 8.0 : 16.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
side: BorderSide(color: theme.colorScheme.surfaceContainerHighest),
|
||||
),
|
||||
);
|
||||
final iconButtonStyleActive = IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.inverseSurface,
|
||||
disabledBackgroundColor: theme.colorScheme.inverseSurface,
|
||||
foregroundColor: theme.colorScheme.onInverseSurface,
|
||||
disabledForegroundColor: theme.colorScheme.onInverseSurface,
|
||||
).merge(iconButtonStyle);
|
||||
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeBottom: mini,
|
||||
removeTop: mini,
|
||||
child: ScaffoldMessenger(
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: viewModel.startTime == null
|
||||
? null
|
||||
: StartTime(startTime: viewModel.startTime!),
|
||||
backgroundColor: theme.colorScheme.surface.withAlpha(64),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.fullscreen_outlined),
|
||||
onPressed: () {
|
||||
Matrix.of(context).callPosition.value = mini
|
||||
? .fullScreen
|
||||
: .top;
|
||||
},
|
||||
),
|
||||
if (localVideoTrack == null)
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => viewModel.selectCamera(context),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Icons.camera_outlined),
|
||||
Text(L10n.of(context).selectCamera),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => viewModel.selectMicrophone(context),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Icons.mic_outlined),
|
||||
Text(L10n.of(context).selectMicrophone),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => viewModel.selectSpeaker(context),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Icons.speaker_phone_outlined),
|
||||
Text(L10n.of(context).selectSpeaker),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
titleSpacing: mini ? 0 : null,
|
||||
title: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
room.getLocalizedDisplayname(),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: mini
|
||||
? TextStyle(fontSize: 11, fontWeight: .bold)
|
||||
: null,
|
||||
),
|
||||
if (activeMembers > 0)
|
||||
Text(
|
||||
viewModel.waitForOtherSide
|
||||
? L10n.of(context).waitingForParticipant
|
||||
: L10n.of(
|
||||
context,
|
||||
).countActiveCallMembers(activeMembers),
|
||||
maxLines: 1,
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: liveKitRoom == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: localVideoTrack != null
|
||||
? Stack(
|
||||
children: [
|
||||
localVideoTrack.muted
|
||||
? Center(
|
||||
child: Avatar(
|
||||
mxContent: ownUser.avatarUrl,
|
||||
name: ownUser.calcDisplayname(),
|
||||
size: 128,
|
||||
),
|
||||
)
|
||||
: VideoTrackRenderer(localVideoTrack, fit: .cover),
|
||||
|
||||
Align(
|
||||
alignment: .topCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: viewModel.value.error == null
|
||||
? theme.colorScheme.errorContainer
|
||||
.withAlpha(230)
|
||||
: theme.colorScheme.error.withAlpha(230),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
),
|
||||
margin: EdgeInsets.all(16.0),
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
viewModel.value.error?.toLocalizedString(
|
||||
context,
|
||||
) ??
|
||||
L10n.of(context).videoCallsBetaWarning,
|
||||
textAlign: .center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: viewModel.value.error == null
|
||||
? theme.colorScheme.onErrorContainer
|
||||
: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SafeArea(
|
||||
top: !mini,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final axis =
|
||||
constraints.maxWidth < constraints.maxHeight
|
||||
? Axis.vertical
|
||||
: Axis.horizontal;
|
||||
|
||||
final tiles = liveKitRoom.getCallTiles(room);
|
||||
|
||||
final focused =
|
||||
(viewModel.value.focusedTrack == null
|
||||
? null
|
||||
: tiles.firstWhereOrNull(
|
||||
(tile) =>
|
||||
tile.id ==
|
||||
viewModel.value.focusedTrack,
|
||||
)) ??
|
||||
tiles.firstWhereOrNull(
|
||||
(tile) => tile.video?.isScreenShare == true,
|
||||
) ??
|
||||
(tiles.length < 3 ? tiles.first : null);
|
||||
|
||||
if (mini) {
|
||||
final miniFocused =
|
||||
focused ??
|
||||
tiles.firstOrNull ??
|
||||
(
|
||||
id: '${ownUser.id}_fallback_none',
|
||||
user: ownUser,
|
||||
video: null,
|
||||
audio: null,
|
||||
connected: false,
|
||||
isSpeaking: false,
|
||||
);
|
||||
return SizedBox.expand(
|
||||
child: CallTile(
|
||||
key: ValueKey(miniFocused.id),
|
||||
user: miniFocused.user,
|
||||
video: miniFocused.video,
|
||||
audio: miniFocused.audio,
|
||||
connected: miniFocused.connected,
|
||||
margin: EdgeInsets.zero,
|
||||
handRaised: viewModel.participantRaisedHand(
|
||||
miniFocused.user.id,
|
||||
),
|
||||
isSpeaking: miniFocused.isSpeaking,
|
||||
onTap: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
tiles.remove(focused);
|
||||
|
||||
return focused == null
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 84.0,
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 16.0,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final rows = sqrt(tiles.length).ceil();
|
||||
final columns = (tiles.length / rows)
|
||||
.ceil();
|
||||
|
||||
final width =
|
||||
(constraints.maxWidth / rows) -
|
||||
(rows * 8);
|
||||
final height =
|
||||
(constraints.maxHeight / columns) -
|
||||
(columns * 8);
|
||||
return Center(
|
||||
child: Wrap(
|
||||
spacing: 16.0,
|
||||
runSpacing: 16.0,
|
||||
alignment: .center,
|
||||
runAlignment: .center,
|
||||
crossAxisAlignment: .center,
|
||||
children: tiles
|
||||
.map(
|
||||
(tile) => CallTile(
|
||||
key: ValueKey(tile.id),
|
||||
user: tile.user,
|
||||
video: tile.video,
|
||||
audio: tile.audio,
|
||||
connected: tile.connected,
|
||||
width: width,
|
||||
height: height,
|
||||
isSpeaking: tile.isSpeaking,
|
||||
handRaised: viewModel
|
||||
.participantRaisedHand(
|
||||
tile.user.id,
|
||||
),
|
||||
onTap: () => viewModel
|
||||
.setFocusedTrack(tile.id),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 96.0,
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 16.0,
|
||||
),
|
||||
child: Flex(
|
||||
direction: axis,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: CallTile(
|
||||
key: ValueKey(focused.id),
|
||||
user: focused.user,
|
||||
video: focused.video,
|
||||
audio: focused.audio,
|
||||
connected: focused.connected,
|
||||
fit: .contain,
|
||||
isSpeaking: focused.isSpeaking,
|
||||
handRaised: viewModel
|
||||
.participantRaisedHand(
|
||||
focused.user.id,
|
||||
),
|
||||
onTap: () => viewModel
|
||||
.setFocusedTrack(focused.id),
|
||||
),
|
||||
),
|
||||
if (tiles.isNotEmpty) ...[
|
||||
SizedBox(width: 16, height: 16),
|
||||
SizedBox(
|
||||
height: axis == .horizontal
|
||||
? null
|
||||
: tileSize,
|
||||
width: axis == .vertical
|
||||
? null
|
||||
: tileSize,
|
||||
child: ListView.builder(
|
||||
scrollDirection: axis == .horizontal
|
||||
? .vertical
|
||||
: .horizontal,
|
||||
itemCount: tiles.length,
|
||||
itemBuilder: (context, i) =>
|
||||
CallTile(
|
||||
key: ValueKey(tiles[i].id),
|
||||
user: tiles[i].user,
|
||||
video: tiles[i].video,
|
||||
audio: tiles[i].audio,
|
||||
connected: tiles[i].connected,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
isSpeaking:
|
||||
tiles[i].isSpeaking,
|
||||
handRaised: viewModel
|
||||
.participantRaisedHand(
|
||||
tiles[i].user.id,
|
||||
),
|
||||
onTap: () =>
|
||||
viewModel.setFocusedTrack(
|
||||
tiles[i].id,
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
right: axis == .vertical
|
||||
? 16
|
||||
: 0,
|
||||
bottom: axis == .horizontal
|
||||
? 16
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
floatingActionButtonLocation: .centerFloat,
|
||||
floatingActionButton: Wrap(
|
||||
alignment: .center,
|
||||
spacing: mini ? 8 : 16,
|
||||
children: [
|
||||
if (localParticipant != null) ...[
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).toggleMicrophone,
|
||||
style: iconButtonStyle,
|
||||
onPressed: () => localParticipant.setMicrophoneEnabled(
|
||||
!localParticipant.isMicrophoneEnabled(),
|
||||
),
|
||||
icon: Icon(
|
||||
localParticipant.isMicrophoneEnabled()
|
||||
? Icons.mic_outlined
|
||||
: Icons.mic_off_outlined,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).toggleCamera,
|
||||
style: iconButtonStyle,
|
||||
onPressed: () => localParticipant.setCameraEnabled(
|
||||
!localParticipant.isCameraEnabled(),
|
||||
),
|
||||
icon: Icon(
|
||||
localParticipant.isCameraEnabled()
|
||||
? Icons.videocam_outlined
|
||||
: Icons.videocam_off_outlined,
|
||||
),
|
||||
),
|
||||
if (!PlatformInfos.isMobile) // TODO: Fix on mobile?
|
||||
IconButton(
|
||||
tooltip: userIsScreensharing
|
||||
? L10n.of(context).stopScreensharing
|
||||
: L10n.of(context).startScreensharing,
|
||||
style: userIsScreensharing
|
||||
? iconButtonStyleActive
|
||||
: iconButtonStyle,
|
||||
onPressed: () => localParticipant.setScreenShareEnabled(
|
||||
!userIsScreensharing,
|
||||
),
|
||||
icon: Icon(
|
||||
userIsScreensharing
|
||||
? Icons.stop_screen_share
|
||||
: Icons.screen_share_outlined,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: ownHandRaised
|
||||
? L10n.of(context).stopRaiseHand
|
||||
: L10n.of(context).raiseHand,
|
||||
style: ownHandRaised
|
||||
? iconButtonStyleActive
|
||||
: iconButtonStyle,
|
||||
onPressed: () => showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => ownHandRaised
|
||||
? room.lowerHandInMatrixRtcCall(viewModel.timeline!)
|
||||
: room.raiseHandInMatrixRtcCall(),
|
||||
),
|
||||
icon: Icon(
|
||||
ownHandRaised
|
||||
? Icons.front_hand
|
||||
: Icons.front_hand_outlined,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: 212,
|
||||
child: TextButton.icon(
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: viewModel.connect,
|
||||
style: ButtonStyle(
|
||||
padding: iconButtonStyleActive.padding,
|
||||
foregroundColor:
|
||||
iconButtonStyleActive.foregroundColor,
|
||||
backgroundColor:
|
||||
iconButtonStyleActive.backgroundColor,
|
||||
),
|
||||
icon: viewModel.value.isLoading
|
||||
? SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.call_outlined),
|
||||
label: Text(
|
||||
viewModel.value.isLoading
|
||||
? L10n.of(context).loadingPleaseWait
|
||||
: room.hasActiveMatrixRtcCall
|
||||
? L10n.of(context).enterCall
|
||||
: L10n.of(context).startCall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).toggleMicrophone,
|
||||
style: iconButtonStyle,
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: viewModel.togglePreviewMic,
|
||||
icon: Icon(
|
||||
(viewModel.value.startWithAudio)
|
||||
? Icons.mic_outlined
|
||||
: Icons.mic_off_outlined,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).toggleCamera,
|
||||
style: iconButtonStyle,
|
||||
onPressed: viewModel.value.isLoading
|
||||
? null
|
||||
: localVideoTrack != null
|
||||
? viewModel.togglePreviewCamera
|
||||
: null,
|
||||
icon: Icon(
|
||||
!(localVideoTrack?.muted ?? true)
|
||||
? Icons.videocam_outlined
|
||||
: Icons.videocam_off_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: L10n.of(context).hangUp,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.errorContainer,
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
).merge(iconButtonStyle),
|
||||
|
||||
onPressed: () => viewModel.close(context),
|
||||
icon: Icon(Icons.call_end_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/pages/call/call_tile.dart
Normal file
140
lib/pages/call/call_tile.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class CallTile extends StatelessWidget {
|
||||
final lk.TrackPublication<lk.VideoTrack>? video;
|
||||
final lk.TrackPublication<lk.AudioTrack>? audio;
|
||||
final User user;
|
||||
final EdgeInsets? margin;
|
||||
final VoidCallback? onTap;
|
||||
final lk.VideoViewFit fit;
|
||||
final double width, height;
|
||||
final bool handRaised, connected, isSpeaking;
|
||||
|
||||
const CallTile({
|
||||
super.key,
|
||||
this.video,
|
||||
this.audio,
|
||||
this.width = 256.0,
|
||||
this.height = 256.0,
|
||||
required this.user,
|
||||
required this.onTap,
|
||||
this.handRaised = false,
|
||||
this.fit = lk.VideoViewFit.cover,
|
||||
this.margin,
|
||||
required this.connected,
|
||||
required this.isSpeaking,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final video = this.video?.track;
|
||||
final theme = Theme.of(context);
|
||||
final borderWidth = isSpeaking ? 5.0 : 1.0;
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: AnimatedContainer(
|
||||
margin: margin,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
border: BoxBorder.all(
|
||||
color: isSpeaking
|
||||
? theme.colorScheme.primaryFixedDim
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
width: borderWidth,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
video != null && !video.muted
|
||||
? lk.VideoTrackRenderer(video, fit: fit)
|
||||
: Center(
|
||||
child: Avatar(
|
||||
size: 64,
|
||||
mxContent: user.avatarUrl,
|
||||
name: user.calcDisplayname(),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
if (handRaised)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(
|
||||
Icons.front_hand_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
child: Material(
|
||||
elevation: theme.appBarTheme.elevation ?? 4,
|
||||
shadowColor: theme.appBarTheme.shadowColor,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceBright.withAlpha(230),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (audio != null)
|
||||
Icon(
|
||||
audio?.muted == false ? Icons.mic : Icons.mic_off,
|
||||
size: 12,
|
||||
),
|
||||
if (this.video?.isScreenShare == true)
|
||||
Icon(Icons.screen_share_outlined, size: 12),
|
||||
Text(
|
||||
user.calcDisplayname(),
|
||||
maxLines: 1,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (!connected)
|
||||
Icon(Icons.portable_wifi_off_outlined, size: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
621
lib/pages/call/call_view_model.dart
Normal file
621
lib/pages/call/call_view_model.dart
Normal file
@@ -0,0 +1,621 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/call_kit_params.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/call_keys_event_content.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call_member.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/utils/position_from_build_context.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class CallViewModelState {
|
||||
lk.Room? room;
|
||||
lk.LocalVideoTrack? localVideoTrack;
|
||||
bool startWithAudio = true;
|
||||
bool isLoading = false;
|
||||
Object? error;
|
||||
String? focusedTrack;
|
||||
}
|
||||
|
||||
class CallViewModel extends ValueNotifier<CallViewModelState> {
|
||||
final Room room;
|
||||
Timeline? timeline;
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
DateTime? startTime;
|
||||
StreamSubscription? _onCallEncryptionKeysSub, _onCallMembersChanged;
|
||||
Timer? _resendCallMemberState;
|
||||
bool waitForOtherSide = false;
|
||||
String? callKitId;
|
||||
|
||||
Set<String> _lastSharedParticipants = {};
|
||||
DateTime? _keyCreatedAt;
|
||||
Uint8List? _lastKey;
|
||||
|
||||
lk.BaseKeyProvider? _keyProvider;
|
||||
|
||||
CallViewModel({required this.room}) : super(CallViewModelState()) {
|
||||
_init();
|
||||
}
|
||||
|
||||
void setFocusedTrack(String trackId) {
|
||||
Logs().d('Set focus on', trackId);
|
||||
if (value.focusedTrack == trackId) {
|
||||
value.focusedTrack = null;
|
||||
} else {
|
||||
value.focusedTrack = trackId;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _onCallEncryptionKeys(CallKeysEvent event) async {
|
||||
final callKeys = event.callKeysContent;
|
||||
final keyProvider = _keyProvider;
|
||||
if (keyProvider == null) {
|
||||
ErrorReporter(
|
||||
null,
|
||||
'Received a new key but the keyProvider is not ready yet!',
|
||||
).onErrorCallback(Exception(), StackTrace.current);
|
||||
return;
|
||||
}
|
||||
|
||||
await keyProvider.setRawKey(
|
||||
base64Decode(callKeys.keys.key),
|
||||
participantId: '${event.sender}:${callKeys.member.claimedDeviceId}',
|
||||
keyIndex: callKeys.keys.index,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createKeyAndShare() async {
|
||||
final liveKitRoom = value.room;
|
||||
if (liveKitRoom == null) return;
|
||||
|
||||
final ownMemberId = '${room.client.userID}:${room.client.deviceID}';
|
||||
|
||||
final currentParticipants =
|
||||
room
|
||||
.getActiveMatrixRtcMembers()
|
||||
.map((member) => member.membershipId)
|
||||
.whereType<String>()
|
||||
.toSet()
|
||||
..remove(ownMemberId);
|
||||
|
||||
if (setEquals(currentParticipants, _lastSharedParticipants) &&
|
||||
currentParticipants.isNotEmpty) {
|
||||
Logs().d(
|
||||
'Participant list has not changed. No need to share keys again!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var index = liveKitRoom.roomOptions.encryption!.keyProvider.getLatestIndex(
|
||||
ownMemberId,
|
||||
);
|
||||
|
||||
final keyCreatedAt = _keyCreatedAt;
|
||||
final canJustForwardToNewUsers =
|
||||
keyCreatedAt != null &&
|
||||
DateTime.now().difference(keyCreatedAt).inSeconds < 15 &&
|
||||
_lastSharedParticipants.difference(currentParticipants).isEmpty;
|
||||
|
||||
late final Uint8List key;
|
||||
if (_lastKey == null || !canJustForwardToNewUsers) {
|
||||
// Key generation
|
||||
final rng = Random.secure();
|
||||
key = Uint8List(16);
|
||||
key.setAll(0, Iterable.generate(key.length, (i) => rng.nextInt(256)));
|
||||
if (_lastKey != null) index = (index + 1) % 256;
|
||||
|
||||
await liveKitRoom.roomOptions.encryption!.keyProvider.setRawKey(
|
||||
key,
|
||||
keyIndex: index,
|
||||
participantId: ownMemberId,
|
||||
);
|
||||
_keyCreatedAt = DateTime.now();
|
||||
_lastKey = key;
|
||||
if (_lastKey != null) {
|
||||
await liveKitRoom.e2eeManager?.setKeyIndex(
|
||||
index,
|
||||
participantIdentity: ownMemberId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
key = _lastKey!;
|
||||
}
|
||||
|
||||
final forwardParticipants = canJustForwardToNewUsers
|
||||
? currentParticipants.difference(_lastSharedParticipants)
|
||||
: currentParticipants;
|
||||
final deviceKeys = <DeviceKeys>[];
|
||||
for (final membershipId in forwardParticipants) {
|
||||
final membershipParts = membershipId.split(':');
|
||||
final deviceId = membershipParts.removeLast();
|
||||
final userId = membershipParts.join(':');
|
||||
final keys = room.client.userDeviceKeys[userId]?.deviceKeys[deviceId];
|
||||
if (keys == null) {
|
||||
Logs().w('No device keys found for $membershipId');
|
||||
continue;
|
||||
}
|
||||
deviceKeys.add(keys);
|
||||
}
|
||||
|
||||
_lastSharedParticipants = currentParticipants;
|
||||
await room.shareMatrixRtcCallKey(
|
||||
key: key,
|
||||
index: index,
|
||||
memberId: ownMemberId,
|
||||
deviceKeys: deviceKeys,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
_onCallEncryptionKeysSub = room.client.onCallEncryptionKeys.listen(
|
||||
_onCallEncryptionKeys,
|
||||
);
|
||||
|
||||
await WakelockPlus.enable();
|
||||
await lk.LiveKitClient.initialize();
|
||||
|
||||
// kHKDF matches the key derivation used by element-web's LiveKit JS SDK
|
||||
final keyProviderOptions = rtc.KeyProviderOptions(
|
||||
sharedKey: false,
|
||||
ratchetSalt: Uint8List.fromList('LKFrameEncryptionKey'.codeUnits),
|
||||
ratchetWindowSize: 0,
|
||||
discardFrameWhenCryptorNotReady: true,
|
||||
keyDerivationAlgorithm: rtc.KeyDerivationAlgorithm.kHKDF,
|
||||
keyRingSize: 255,
|
||||
);
|
||||
final nativeKeyProvider = await rtc.frameCryptorFactory
|
||||
.createDefaultKeyProvider(keyProviderOptions);
|
||||
final baseKeyProvider = _keyProvider = lk.BaseKeyProvider(
|
||||
nativeKeyProvider,
|
||||
keyProviderOptions,
|
||||
);
|
||||
|
||||
final liveKitRoom = value.room = lk.Room(
|
||||
roomOptions: lk.RoomOptions(
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
defaultAudioCaptureOptions: lk.AudioCaptureOptions(
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
highPassFilter: true,
|
||||
),
|
||||
defaultScreenShareCaptureOptions: lk.ScreenShareCaptureOptions(
|
||||
useiOSBroadcastExtension: PlatformInfos.isIOS,
|
||||
),
|
||||
defaultAudioOutputOptions: lk.AudioOutputOptions(
|
||||
speakerOn: PlatformInfos.isMobile,
|
||||
),
|
||||
encryption: lk.E2EEOptions(keyProvider: baseKeyProvider),
|
||||
),
|
||||
);
|
||||
|
||||
liveKitRoom.events.on<lk.ParticipantConnectedEvent>((event) async {
|
||||
if (event.participant.identity ==
|
||||
liveKitRoom.localParticipant?.identity) {
|
||||
return;
|
||||
}
|
||||
if (waitForOtherSide) {
|
||||
waitForOtherSide = false;
|
||||
await _audioPlayer.stop();
|
||||
final callKitId = this.callKitId;
|
||||
if (callKitId != null) {
|
||||
await FlutterCallkitIncoming.setCallConnected(callKitId);
|
||||
}
|
||||
}
|
||||
_playJoinSound();
|
||||
});
|
||||
|
||||
liveKitRoom.events.on<lk.ParticipantDisconnectedEvent>(_playLeaveSound);
|
||||
|
||||
liveKitRoom.events.on<lk.TrackE2EEStateEvent>((event) {
|
||||
if (event.state == .kOk || event.state == .kNew) {
|
||||
Logs().i(
|
||||
'E2EE event from ${event.participant} (${event.publication})',
|
||||
event.state,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.state == .kMissingKey) {
|
||||
Logs().d('Waiting for call key...', event);
|
||||
return;
|
||||
}
|
||||
Logs().e('LiveKit E2EE Error', event);
|
||||
});
|
||||
|
||||
liveKitRoom.addListener(notifyListeners);
|
||||
|
||||
if (room.isDirectChat && room.hasActiveMatrixRtcCall) {
|
||||
await connect();
|
||||
} else {
|
||||
value.localVideoTrack = await lk.LocalVideoTrack.createCameraTrack();
|
||||
if (room.getActiveMatrixRtcMembers().firstOrNull?.callIntent == .voice) {
|
||||
value.localVideoTrack?.mute();
|
||||
}
|
||||
value.localVideoTrack?.addListener(notifyListeners);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> togglePreviewCamera() async {
|
||||
final track = value.localVideoTrack;
|
||||
if (track == null) return;
|
||||
if (!track.muted) {
|
||||
await track.mute();
|
||||
return;
|
||||
}
|
||||
track.removeListener(notifyListeners);
|
||||
await track.stop();
|
||||
value.localVideoTrack = await lk.LocalVideoTrack.createCameraTrack();
|
||||
value.localVideoTrack?.addListener(notifyListeners);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void togglePreviewMic() {
|
||||
value.startWithAudio = !value.startWithAudio;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool participantRaisedHand(String matrixId) {
|
||||
final timeline = this.timeline;
|
||||
if (timeline == null) return false;
|
||||
return room.participantRaisedHandInMatrixRtcCall(matrixId, timeline);
|
||||
}
|
||||
|
||||
Future<void> _onNewTimelineEvent(int i) async {
|
||||
final event = timeline?.events[i];
|
||||
if (event == null) return;
|
||||
switch (event.type) {
|
||||
case 'io.element.call.reaction':
|
||||
Logs().d(
|
||||
'Call reactions are not yet handled',
|
||||
event.content.tryGet<String>('emoji'),
|
||||
);
|
||||
// TODO: handle reaction
|
||||
break;
|
||||
case EventTypes.Reaction:
|
||||
final reactedEvent = await timeline!.getEventById(
|
||||
event.relationshipEventId!,
|
||||
);
|
||||
if (reactedEvent != null &&
|
||||
reactedEvent.type == MatrixRtcCallMember.eventType &&
|
||||
event.senderId == reactedEvent.senderId &&
|
||||
event.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key') ==
|
||||
'🖐️') {
|
||||
Logs().d('User raised hand!');
|
||||
_playRaiseHandSound();
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
case EventTypes.Redaction:
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect() async {
|
||||
try {
|
||||
value.isLoading = true;
|
||||
notifyListeners();
|
||||
final playWaitingSound =
|
||||
!room.hasActiveMatrixRtcCall && room.isDirectChat;
|
||||
|
||||
final intent =
|
||||
room.getActiveMatrixRtcMembers().firstOrNull?.callIntent ??
|
||||
(value.localVideoTrack?.muted == false ? .video : .voice);
|
||||
if (PlatformInfos.isMobile) {
|
||||
await lk.AudioManager.instance.setSpeakerOutputPreferred(
|
||||
intent == .video,
|
||||
);
|
||||
}
|
||||
|
||||
timeline = await room.getTimeline(onInsert: _onNewTimelineEvent);
|
||||
final credentials = await room.joinMatrixRtcCall(intent: intent);
|
||||
_onCallMembersChanged = room.client.onSync.stream
|
||||
.where(
|
||||
(syncUpdate) =>
|
||||
syncUpdate.rooms?.join?[room.id]?.timeline?.events?.any(
|
||||
(event) => event.type == MatrixRtcCallMember.eventType,
|
||||
) ??
|
||||
false,
|
||||
)
|
||||
.listen((_) {
|
||||
notifyListeners();
|
||||
_createKeyAndShare();
|
||||
});
|
||||
await _createKeyAndShare();
|
||||
|
||||
final startWithVideo =
|
||||
!(value.localVideoTrack?.muted ?? intent == .voice);
|
||||
final startWithAudio = value.startWithAudio;
|
||||
await value.localVideoTrack?.stop();
|
||||
await value.localVideoTrack?.dispose();
|
||||
value.localVideoTrack = null;
|
||||
await value.room!.connect(
|
||||
credentials.url,
|
||||
credentials.jwt,
|
||||
fastConnectOptions: lk.FastConnectOptions(
|
||||
microphone: lk.TrackOption(
|
||||
track: startWithAudio ? await lk.LocalAudioTrack.create() : null,
|
||||
),
|
||||
camera: lk.TrackOption(
|
||||
track: startWithVideo
|
||||
? await lk.LocalVideoTrack.createCameraTrack()
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_resendCallMemberState?.cancel();
|
||||
_resendCallMemberState = Timer.periodic(const Duration(hours: 1), (_) {
|
||||
final ownMembership = room.ownMatrixRtcMembership;
|
||||
if (ownMembership == null) {
|
||||
_resendCallMemberState?.cancel();
|
||||
return;
|
||||
}
|
||||
room.setMatrixRtcMembershipState(
|
||||
ownMembership.fociPreferred,
|
||||
intent: intent,
|
||||
);
|
||||
});
|
||||
startTime = DateTime.now();
|
||||
|
||||
if (PlatformInfos.isMobile) {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
callKitId = activeCalls
|
||||
.firstWhereOrNull(
|
||||
(call) =>
|
||||
call.extra?['roomId'] == room.id &&
|
||||
call.extra?['clientName'] == room.client.clientName,
|
||||
)
|
||||
?.id;
|
||||
if (callKitId == null) {
|
||||
final params = await buildFluffyChatCallKitParams(
|
||||
room,
|
||||
intent: intent,
|
||||
);
|
||||
await FlutterCallkitIncoming.startCall(params);
|
||||
callKitId = params.id;
|
||||
}
|
||||
}
|
||||
if (playWaitingSound) {
|
||||
waitForOtherSide = true;
|
||||
_playWaitingSound();
|
||||
} else {
|
||||
final callKitId = this.callKitId;
|
||||
if (callKitId != null) {
|
||||
await FlutterCallkitIncoming.setCallConnected(callKitId);
|
||||
}
|
||||
_playJoinSound();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e, s) {
|
||||
Logs().e('Error while connecting', e, s);
|
||||
value.error = e;
|
||||
ErrorReporter(null, 'Unable to connect').onErrorCallback(e, s);
|
||||
} finally {
|
||||
value.isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close(BuildContext context) async {
|
||||
_playLeaveSound();
|
||||
final liveKitRoom = value.room;
|
||||
if (liveKitRoom != null) {
|
||||
liveKitRoom.removeListener(notifyListeners);
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
if (liveKitRoom.connectionState != lk.ConnectionState.disconnected) {
|
||||
await liveKitRoom.disconnect();
|
||||
}
|
||||
await liveKitRoom.dispose();
|
||||
value.room = null;
|
||||
if (room.ownMatrixRtcMembership != null) {
|
||||
await room.leaveMatrixRtcCall();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
Matrix.of(context).activeCallRoomId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onCallEncryptionKeysSub?.cancel();
|
||||
_onCallMembersChanged?.cancel();
|
||||
_resendCallMemberState?.cancel();
|
||||
timeline?.cancelSubscriptions();
|
||||
value.localVideoTrack?.dispose();
|
||||
final callKitId = this.callKitId;
|
||||
if (callKitId != null) {
|
||||
FlutterCallkitIncoming.endCall(callKitId);
|
||||
}
|
||||
final liveKitRoom = value.room;
|
||||
if (liveKitRoom != null) {
|
||||
liveKitRoom.removeListener(notifyListeners);
|
||||
if (liveKitRoom.connectionState != lk.ConnectionState.disconnected) {
|
||||
liveKitRoom.disconnect();
|
||||
}
|
||||
liveKitRoom.dispose();
|
||||
room.leaveMatrixRtcCall();
|
||||
}
|
||||
_audioPlayer.dispose();
|
||||
WakelockPlus.disable();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _playSoundIndex(
|
||||
AudioSource audioSource, {
|
||||
LoopMode loopMode = .off,
|
||||
}) async {
|
||||
if (_audioPlayer.playing) await _audioPlayer.stop();
|
||||
await _audioPlayer.setAudioSource(audioSource);
|
||||
if (_audioPlayer.loopMode != loopMode) {
|
||||
await _audioPlayer.setLoopMode(loopMode);
|
||||
}
|
||||
try {
|
||||
await _audioPlayer.play();
|
||||
} catch (e) {
|
||||
Logs().w('Unable to start audio player. Try again...', e);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await _audioPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
final _joinSource = AudioSource.asset('assets/sounds/call_join.mp3');
|
||||
final _leaveSource = AudioSource.asset('assets/sounds/call_leave.mp3');
|
||||
final _raiseHandSource = AudioSource.asset(
|
||||
'assets/sounds/call_attention.mp3',
|
||||
);
|
||||
final _waitSource = AudioSource.asset('assets/sounds/call_wait.mp3');
|
||||
|
||||
Future<void> _playJoinSound() => _playSoundIndex(_joinSource);
|
||||
|
||||
Future<void> _playLeaveSound([_]) => _playSoundIndex(_leaveSource);
|
||||
|
||||
Future<void> _playRaiseHandSound() => _playSoundIndex(_raiseHandSource);
|
||||
|
||||
Future<void> _playWaitingSound() =>
|
||||
_playSoundIndex(_waitSource, loopMode: .all);
|
||||
|
||||
Future<lk.MediaDevice?> _selectMediaDevice(
|
||||
BuildContext context,
|
||||
List<lk.MediaDevice> devices,
|
||||
String? activeDeviceId,
|
||||
) async {
|
||||
if (!context.mounted) return null;
|
||||
return await showMenu<lk.MediaDevice>(
|
||||
context: context,
|
||||
position: context.position,
|
||||
items: devices.isEmpty
|
||||
? [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text(L10n.of(context).noDevicesFound),
|
||||
),
|
||||
]
|
||||
: devices
|
||||
.map(
|
||||
(device) => PopupMenuItem(
|
||||
value: device,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Icon(
|
||||
device.deviceId == activeDeviceId
|
||||
? Icons.check_circle_outlined
|
||||
: Icons.circle_outlined,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
device.label,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectCamera(BuildContext context) async {
|
||||
final devices = await lk.Hardware.instance.videoInputs();
|
||||
if (!context.mounted) return;
|
||||
final source = await _selectMediaDevice(
|
||||
context,
|
||||
devices,
|
||||
value.room?.selectedVideoInputDeviceId,
|
||||
);
|
||||
if (source == null) return;
|
||||
await value.room?.setVideoInputDevice(source);
|
||||
}
|
||||
|
||||
Future<void> selectMicrophone(BuildContext context) async {
|
||||
final devices = await lk.Hardware.instance.audioInputs();
|
||||
if (!context.mounted) return;
|
||||
final source = await _selectMediaDevice(
|
||||
context,
|
||||
devices,
|
||||
value.room?.selectedAudioInputDeviceId,
|
||||
);
|
||||
if (source == null) return;
|
||||
await value.room?.setAudioInputDevice(source);
|
||||
}
|
||||
|
||||
Future<void> selectSpeaker(BuildContext context) async {
|
||||
final devices = await lk.Hardware.instance.audioOutputs();
|
||||
if (!context.mounted) return;
|
||||
if (devices.isEmpty && PlatformInfos.isMobile) {
|
||||
final preferSpeaker = lk.AudioManager.instance.isSpeakerOutputPreferred;
|
||||
final toggle = await showMenu<bool>(
|
||||
context: context,
|
||||
position: context.position,
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: true,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Icon(
|
||||
preferSpeaker
|
||||
? Icons.check_circle_outlined
|
||||
: Icons.circle_outlined,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
L10n.of(context).preferSpeaker,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (toggle == null) return;
|
||||
await lk.AudioManager.instance.setSpeakerOutputPreferred(!preferSpeaker);
|
||||
return;
|
||||
}
|
||||
final source = await _selectMediaDevice(
|
||||
context,
|
||||
devices,
|
||||
value.room?.selectedAudioOutputDeviceId,
|
||||
);
|
||||
if (source == null) return;
|
||||
await value.room?.setAudioOutputDevice(source);
|
||||
}
|
||||
}
|
||||
43
lib/pages/call/start_time.dart
Normal file
43
lib/pages/call/start_time.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class StartTime extends StatefulWidget {
|
||||
final DateTime startTime;
|
||||
const StartTime({required this.startTime, super.key});
|
||||
|
||||
@override
|
||||
State<StartTime> createState() => _StartTimeState();
|
||||
}
|
||||
|
||||
class _StartTimeState extends State<StartTime> {
|
||||
late final Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = DateTime.now().difference(widget.startTime);
|
||||
return Center(
|
||||
child: Text(
|
||||
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/pages/call/utils/get_call_tiles.dart
Normal file
109
lib/pages/call/utils/get_call_tiles.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
typedef TileData = ({
|
||||
String id,
|
||||
lk.TrackPublication<lk.VideoTrack>? video,
|
||||
lk.TrackPublication<lk.AudioTrack>? audio,
|
||||
User user,
|
||||
bool connected,
|
||||
bool isSpeaking,
|
||||
});
|
||||
|
||||
extension GetCallTiles on lk.Room {
|
||||
/// Calculates all call tiles to be rendered for this call in this matrix
|
||||
/// room. For users without any camera track it adds a CallTile with a
|
||||
/// null video track at least.
|
||||
List<TileData> getCallTiles(Room room) {
|
||||
final tiles = <TileData>[];
|
||||
|
||||
for (final member in room.getActiveMatrixRtcMembers()) {
|
||||
final participantId = '${member.senderId}:${member.deviceId}';
|
||||
|
||||
// The local user is handled below:
|
||||
if (localParticipant?.identity == participantId ||
|
||||
(member.senderId == room.client.userID &&
|
||||
member.deviceId == room.client.deviceID)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final participant =
|
||||
remoteParticipants[participantId] ??
|
||||
remoteParticipants[member.membershipId];
|
||||
final matrixId = member.senderId ?? participant?.matrixId;
|
||||
if (matrixId == null) {
|
||||
Logs().wtf('No matrix ID found for this member!');
|
||||
continue;
|
||||
}
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
|
||||
final audio = participant?.audioTrackPublications.firstOrNull;
|
||||
if (participant == null ||
|
||||
!participant.videoTrackPublications.any(
|
||||
(pub) => !pub.isScreenShare,
|
||||
)) {
|
||||
tiles.add((
|
||||
id: '${participantId}_none',
|
||||
user: user,
|
||||
video: null,
|
||||
audio: audio,
|
||||
connected: participant != null,
|
||||
isSpeaking: participant?.isSpeaking == true,
|
||||
));
|
||||
}
|
||||
for (final pub
|
||||
in participant?.videoTrackPublications ??
|
||||
<lk.RemoteTrackPublication<lk.RemoteVideoTrack>>[]) {
|
||||
tiles.add((
|
||||
id: '${participantId}_${pub.name}',
|
||||
user: user,
|
||||
video: pub,
|
||||
audio: pub.isScreenShare ? null : audio,
|
||||
connected: participant != null,
|
||||
isSpeaking: participant?.isSpeaking == true && !pub.isScreenShare,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the local participant here:
|
||||
final ownUser = room.unsafeGetUserFromMemoryOrFallback(room.client.userID!);
|
||||
for (final pub
|
||||
in localParticipant?.videoTrackPublications ??
|
||||
<lk.LocalTrackPublication<lk.LocalVideoTrack>>[]) {
|
||||
tiles.add((
|
||||
id: '${localParticipant?.identity ?? ownUser.id}_${pub.name}',
|
||||
user: ownUser,
|
||||
video: pub,
|
||||
audio: localParticipant?.audioTrackPublications.firstOrNull,
|
||||
connected: localParticipant != null,
|
||||
isSpeaking: localParticipant?.isSpeaking == true && !pub.isScreenShare,
|
||||
));
|
||||
}
|
||||
|
||||
if (localParticipant?.videoTrackPublications.any(
|
||||
(pub) => !pub.isScreenShare,
|
||||
) !=
|
||||
true) {
|
||||
tiles.add((
|
||||
id: '${localParticipant?.identity ?? ownUser.id}_none',
|
||||
user: ownUser,
|
||||
video: null,
|
||||
audio: localParticipant?.audioTrackPublications.firstOrNull,
|
||||
connected: localParticipant != null,
|
||||
isSpeaking: localParticipant?.isSpeaking == true,
|
||||
));
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
}
|
||||
|
||||
extension on lk.RemoteParticipant {
|
||||
String get matrixId => (identity.split(':')..removeLast()).join(':');
|
||||
}
|
||||
1665
lib/pages/chat/chat.dart
Normal file
1665
lib/pages/chat/chat.dart
Normal file
File diff suppressed because it is too large
Load Diff
68
lib/pages/chat/chat_app_bar_list_tile.dart
Normal file
68
lib/pages/chat/chat_app_bar_list_tile.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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/utils/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class ChatAppBarListTile extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final void Function()? onTap;
|
||||
|
||||
static const double fixedHeight = 40.0;
|
||||
|
||||
const ChatAppBarListTile({
|
||||
super.key,
|
||||
this.leading,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final leading = this.leading;
|
||||
final trailing = this.trailing;
|
||||
return SizedBox(
|
||||
height: fixedHeight,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
?leading,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Linkify(
|
||||
text: title,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14,
|
||||
),
|
||||
linkStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/pages/chat/chat_app_bar_title.dart
Normal file
179
lib/pages/chat/chat_app_bar_title.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
// 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/date_time_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/utils/sync_status_localization.dart';
|
||||
import 'package:tatchat/utils/verified_room_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/presence_builder.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class ChatAppBarTitle extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const ChatAppBarTitle(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final room = controller.room;
|
||||
if (controller.selectedEvents.isNotEmpty) {
|
||||
return Text(
|
||||
controller.selectedEvents.length.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
hoverColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onTap: controller.isArchived
|
||||
? null
|
||||
: () => FluffyThemes.isThreeColumnMode(context)
|
||||
? controller.toggleDisplayChatDetailsColumn()
|
||||
: context.go('/rooms/${room.id}/details'),
|
||||
child: Row(
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'content_banner',
|
||||
child: Avatar(
|
||||
mxContent: room.avatar,
|
||||
name: room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (room.allUsersVerified)
|
||||
Icon(
|
||||
Icons.verified,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.getLocalizedDisplayname(
|
||||
MatrixLocals(L10n.of(context)),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StreamBuilder(
|
||||
stream: room.client.onSyncStatus.stream,
|
||||
builder: (context, snapshot) {
|
||||
final status =
|
||||
room.client.onSyncStatus.value ??
|
||||
const SyncStatusUpdate(SyncStatus.waitingForResponse);
|
||||
final hide =
|
||||
FluffyThemes.isColumnMode(context) ||
|
||||
(room.client.onSync.value != null &&
|
||||
status.status != SyncStatus.error &&
|
||||
room.client.prevBatch != null);
|
||||
final style = TextStyle(fontSize: 11);
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
child: hide
|
||||
? room.isDirectChat
|
||||
? PresenceBuilder(
|
||||
userId: room.directChatMatrixID,
|
||||
builder: (context, presence) {
|
||||
final statusMessage = presence?.statusMsg
|
||||
?.trim();
|
||||
|
||||
final lastActiveTimestamp =
|
||||
presence?.lastActiveTimestamp;
|
||||
|
||||
final texts = [
|
||||
if (presence?.currentlyActive == true)
|
||||
L10n.of(context).currentlyActive
|
||||
else if (lastActiveTimestamp != null)
|
||||
L10n.of(context).lastActiveAgo(
|
||||
lastActiveTimestamp
|
||||
.localizedTimeShort(context),
|
||||
),
|
||||
?statusMessage,
|
||||
];
|
||||
final text = texts.join(' ◦ ');
|
||||
if (text.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Text(text, style: style);
|
||||
},
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).countParticipants(
|
||||
(room.summary.mJoinedMemberCount ??
|
||||
1) +
|
||||
(room
|
||||
.summary
|
||||
.mInvitedMemberCount ??
|
||||
0),
|
||||
),
|
||||
maxLines: 1,
|
||||
style: style,
|
||||
),
|
||||
if (room.topic.isNotEmpty) ...[
|
||||
Text(' ◦ ', style: style),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.topic,
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 10,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 1,
|
||||
value: status.progress,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
status.calcLocalizedString(context),
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/pages/chat/chat_emoji_picker.dart
Normal file
125
lib/pages/chat/chat_emoji_picker.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/sticker_picker_dialog.dart';
|
||||
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'chat.dart';
|
||||
|
||||
class ChatEmojiPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
const ChatEmojiPicker(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: const BoxDecoration(),
|
||||
height: controller.showEmojiPicker
|
||||
? MediaQuery.sizeOf(context).height / 2
|
||||
: 0,
|
||||
child: controller.showEmojiPicker
|
||||
? DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: L10n.of(context).emojis),
|
||||
Tab(text: L10n.of(context).stickers),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
EmojiPicker(
|
||||
onEmojiSelected: controller.onEmojiSelected,
|
||||
onBackspacePressed: controller.emojiPickerBackspace,
|
||||
config: Config(
|
||||
locale: Localizations.localeOf(context),
|
||||
emojiViewConfig: EmojiViewConfig(
|
||||
noRecents: const NoRecent(),
|
||||
backgroundColor:
|
||||
theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
bottomActionBarConfig: const BottomActionBarConfig(
|
||||
enabled: false,
|
||||
),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
backspaceColor: theme.colorScheme.primary,
|
||||
iconColor: theme.colorScheme.primary.withAlpha(
|
||||
128,
|
||||
),
|
||||
iconColorSelected: theme.colorScheme.primary,
|
||||
indicatorColor: theme.colorScheme.primary,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
skinToneConfig: SkinToneConfig(
|
||||
dialogBackgroundColor: Color.lerp(
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.primaryContainer,
|
||||
0.75,
|
||||
)!,
|
||||
indicatorColor: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
StickerPickerDialog(
|
||||
room: controller.room,
|
||||
onSelected: (sticker) async {
|
||||
final proceed = await showTrustUserInRoomDialog(
|
||||
context,
|
||||
controller.room,
|
||||
);
|
||||
if (!proceed) return;
|
||||
controller.room.sendEvent(
|
||||
{
|
||||
'body': sticker.body,
|
||||
'info': sticker.info ?? {},
|
||||
'url': sticker.url.toString(),
|
||||
},
|
||||
type: EventTypes.Sticker,
|
||||
threadRootEventId: controller.activeThreadId,
|
||||
threadLastEventId: controller.threadLastEventId,
|
||||
);
|
||||
controller.hideEmojiPicker();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NoRecent extends StatelessWidget {
|
||||
const NoRecent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
L10n.of(context).emoteKeyboardNoRecents,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/pages/chat/chat_event_list.dart
Normal file
253
lib/pages/chat/chat_event_list.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat/encryption_info.dart';
|
||||
import 'package:tatchat/pages/chat/events/message.dart';
|
||||
import 'package:tatchat/pages/chat/seen_by_row.dart';
|
||||
import 'package:tatchat/pages/chat/typing_indicators.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix_api_lite/model/event_types.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../../utils/date_time_extension.dart';
|
||||
|
||||
class ChatEventList extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatEventList({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timeline = controller.timeline;
|
||||
|
||||
if (timeline == null) {
|
||||
return const Center(child: CupertinoActivityIndicator());
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final colors = [theme.secondaryBubbleColor, theme.bubbleColor];
|
||||
|
||||
final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0;
|
||||
|
||||
final events = timeline.events.filterByVisibleInGui(
|
||||
threadId: controller.activeThreadId,
|
||||
);
|
||||
|
||||
// create a map of eventId --> index to greatly improve performance of
|
||||
// ListView's findChildIndexCallback
|
||||
final thisEventsKeyMap = <String, int>{};
|
||||
for (var i = 0; i < events.length; i++) {
|
||||
thisEventsKeyMap[events[i].eventId] = i;
|
||||
}
|
||||
|
||||
final hasWallpaper =
|
||||
controller.room.client.applicationAccountConfig.wallpaperUrl != null;
|
||||
|
||||
return SelectionArea(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(AppSettings.fontSizeFactor.value),
|
||||
),
|
||||
child: ListView.custom(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8 + MediaQuery.paddingOf(context).top,
|
||||
bottom:
|
||||
8 +
|
||||
(controller.inputBarHeight ??
|
||||
(72 + (FluffyThemes.isColumnMode(context) ? 16 : 0))),
|
||||
left: horizontalPadding,
|
||||
right: horizontalPadding,
|
||||
),
|
||||
reverse: true,
|
||||
controller: controller.scrollController,
|
||||
keyboardDismissBehavior: PlatformInfos.isIOS
|
||||
? ScrollViewKeyboardDismissBehavior.onDrag
|
||||
: ScrollViewKeyboardDismissBehavior.manual,
|
||||
childrenDelegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int i) {
|
||||
// Footer to display typing indicator and read receipts:
|
||||
if (i == 0) {
|
||||
if (timeline.canRequestFuture) {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: timeline.isRequestingFuture
|
||||
? null
|
||||
: controller.requestFuture,
|
||||
icon: timeline.isRequestingFuture
|
||||
? CircularProgressIndicator.adaptive(strokeWidth: 2)
|
||||
: const Icon(Icons.arrow_downward_outlined),
|
||||
label: Text(L10n.of(context).loadMore),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (events.isNotEmpty) SeenByRow(event: events.first),
|
||||
TypingIndicators(controller),
|
||||
EncryptionInfo(room: controller.room),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Request history button or progress indicator:
|
||||
if (i == events.length + 1) {
|
||||
if (controller.activeThreadId != null ||
|
||||
!timeline.canRequestHistory) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final visibleIndex = timeline.events.lastIndexWhere(
|
||||
(event) =>
|
||||
!event.isCollapsedState && event.isVisibleInGui,
|
||||
);
|
||||
if (visibleIndex > timeline.events.length - 50) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
controller.requestHistory,
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: timeline.isRequestingHistory
|
||||
? null
|
||||
: controller.requestHistory,
|
||||
icon: timeline.isRequestingHistory
|
||||
? CircularProgressIndicator.adaptive(strokeWidth: 2)
|
||||
: const Icon(Icons.arrow_upward_outlined),
|
||||
label: Text(L10n.of(context).loadMore),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
i--;
|
||||
|
||||
// The message at this index:
|
||||
final event = events[i];
|
||||
final animateIn =
|
||||
(event.transactionId ?? event.eventId) ==
|
||||
controller.animateInEventId;
|
||||
|
||||
final nextEvent = i + 1 < events.length ? events[i + 1] : null;
|
||||
final previousEvent = i > 0 ? events[i - 1] : null;
|
||||
|
||||
// Collapsed state event
|
||||
final canExpand =
|
||||
event.isCollapsedState &&
|
||||
nextEvent?.isCollapsedState == true &&
|
||||
previousEvent?.isCollapsedState != true;
|
||||
final isCollapsed =
|
||||
event.isCollapsedState &&
|
||||
previousEvent?.isCollapsedState == true &&
|
||||
!controller.expandedEventIds.contains(event.eventId);
|
||||
|
||||
final displayDate =
|
||||
event.type == EventTypes.RoomCreate ||
|
||||
nextEvent == null ||
|
||||
!event.originServerTs.sameDay(nextEvent.originServerTs);
|
||||
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(event.transactionId ?? event.eventId),
|
||||
index: i,
|
||||
controller: controller.scrollController,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
if (!isCollapsed && displayDate)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 16.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius * 2,
|
||||
),
|
||||
color: theme.colorScheme.inverseSurface.withAlpha(
|
||||
200,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 2.0,
|
||||
),
|
||||
child: Text(
|
||||
event.originServerTs.localizedDate(context),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Message(
|
||||
event,
|
||||
bigEmojis: controller.bigEmojis,
|
||||
animateIn: animateIn,
|
||||
onSwipe: () => controller.replyAction(replyTo: event),
|
||||
onInfoTab: controller.showEventInfo,
|
||||
onMention: () => controller.sendController.text +=
|
||||
'${event.senderFromMemoryOrFallback.mention} ',
|
||||
highlightMarker:
|
||||
controller.scrollToEventIdMarker == event.eventId,
|
||||
onSelect: controller.onSelectMessage,
|
||||
scrollToEventId: controller.scrollToEventId,
|
||||
longPressSelect: controller.selectedEvents.isNotEmpty,
|
||||
selected: controller.selectedEvents.any(
|
||||
(e) => e.eventId == event.eventId,
|
||||
),
|
||||
singleSelected:
|
||||
controller.selectedEvents.singleOrNull?.eventId ==
|
||||
event.eventId,
|
||||
onEdit: controller.editSelectedEventAction,
|
||||
timeline: timeline,
|
||||
displayReadMarker:
|
||||
i > 0 &&
|
||||
controller.readMarkerEventId == event.eventId,
|
||||
nextEvent: nextEvent,
|
||||
previousEvent: previousEvent,
|
||||
wallpaperMode: hasWallpaper,
|
||||
scrollController: controller.scrollController,
|
||||
colors: colors,
|
||||
isCollapsed: isCollapsed,
|
||||
enterThread: controller.activeThreadId == null
|
||||
? controller.enterThread
|
||||
: null,
|
||||
onExpand: canExpand
|
||||
? () => controller.expandEventsFrom(
|
||||
event,
|
||||
!controller.expandedEventIds.contains(
|
||||
event.eventId,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: events.length + 2,
|
||||
findChildIndexCallback: (key) =>
|
||||
controller.findChildIndexCallback(key, thisEventsKeyMap),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
436
lib/pages/chat/chat_input_row.dart
Normal file
436
lib/pages/chat/chat_input_row.dart
Normal file
@@ -0,0 +1,436 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:emoji_picker_flutter/locales/default_emoji_set_locale.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/recording_input_row.dart';
|
||||
import 'package:tatchat/pages/chat/recording_view_model.dart';
|
||||
import 'package:tatchat/utils/other_party_can_receive.dart';
|
||||
import 'package:tatchat/utils/platform_infos.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/hover_builder.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../config/themes.dart';
|
||||
import 'chat.dart';
|
||||
import 'input_bar.dart';
|
||||
|
||||
class ChatInputRow extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
static const double height = 56.0;
|
||||
|
||||
const ChatInputRow(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final textMessageOnly =
|
||||
controller.sendController.text.isNotEmpty ||
|
||||
controller.replyEvent != null ||
|
||||
controller.editEvent != null;
|
||||
|
||||
if (!controller.room.otherPartyCanReceiveMessages) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
L10n.of(context).otherPartyNotLoggedIn,
|
||||
style: theme.textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selectedTextButtonStyle = TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.onTertiaryContainer,
|
||||
);
|
||||
|
||||
return RecordingViewModel(
|
||||
builder: (context, recordingViewModel) {
|
||||
if (recordingViewModel.isRecording) {
|
||||
return RecordingInputRow(
|
||||
state: recordingViewModel,
|
||||
onSend: controller.onVoiceMessageSend,
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: .end,
|
||||
mainAxisAlignment: .spaceBetween,
|
||||
children: controller.selectMode
|
||||
? <Widget>[
|
||||
if (controller.selectedEvents.every(
|
||||
(event) => event.status == EventStatus.error,
|
||||
))
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: controller.deleteErrorEventsAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.delete_forever_outlined),
|
||||
Text(L10n.of(context).delete),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.forwardEventsAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.keyboard_arrow_left_outlined),
|
||||
Text(L10n.of(context).forward),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
controller.selectedEvents.length == 1
|
||||
? controller.selectedEvents.first
|
||||
.getDisplayEvent(controller.timeline!)
|
||||
.status
|
||||
.isSent
|
||||
? SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.replyAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context).reply),
|
||||
const Icon(Icons.keyboard_arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: height,
|
||||
child: TextButton(
|
||||
style: selectedTextButtonStyle,
|
||||
onPressed: controller.sendAgainAction,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(L10n.of(context).tryToSendAgain),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.send_outlined, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
]
|
||||
: <Widget>[
|
||||
const SizedBox(width: 8),
|
||||
AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
width: textMessageOnly ? 0 : 48,
|
||||
height: height,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: PopupMenuButton<AddPopupMenuActions>(
|
||||
useRootNavigator: true,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: height,
|
||||
width: 48,
|
||||
alignment: Alignment.center,
|
||||
child: IconButton(
|
||||
tooltip: L10n.of(context).emojis,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
icon: Icon(
|
||||
controller.showEmojiPicker
|
||||
? Icons.keyboard
|
||||
: Icons.add_reaction_outlined,
|
||||
key: ValueKey(controller.showEmojiPicker),
|
||||
),
|
||||
onPressed: controller.emojiPickerAction,
|
||||
),
|
||||
),
|
||||
if (Matrix.of(context).isMultiAccount &&
|
||||
Matrix.of(context).hasComplexBundles &&
|
||||
Matrix.of(context).currentBundle!.length > 1)
|
||||
Container(
|
||||
height: height,
|
||||
width: 48,
|
||||
alignment: Alignment.center,
|
||||
child: _ChatAccountPicker(controller),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: InputBar(
|
||||
room: controller.room,
|
||||
minLines: 1,
|
||||
maxLines: 8,
|
||||
autofocus: !PlatformInfos.isMobile,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textInputAction:
|
||||
AppSettings.sendOnEnter.value == true &&
|
||||
PlatformInfos.isMobile
|
||||
? TextInputAction.send
|
||||
: null,
|
||||
onSubmitted: controller.onInputBarSubmitted,
|
||||
onSubmitImage: controller.sendImageFromClipBoard,
|
||||
focusNode: controller.inputFocus,
|
||||
controller: controller.sendController,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 6.0,
|
||||
right: 6.0,
|
||||
bottom: 6.0,
|
||||
top: 3.0,
|
||||
),
|
||||
counter: const SizedBox.shrink(),
|
||||
hintText: controller.room.encrypted
|
||||
? L10n.of(context).encryptedMessage
|
||||
: L10n.of(context).unencryptedMessage,
|
||||
hintMaxLines: 1,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
onChanged: controller.onInputBarChanged,
|
||||
suggestionEmojis:
|
||||
getDefaultEmojiLocale(
|
||||
AppSettings.emojiSuggestionLocale.value.isNotEmpty
|
||||
? Locale(
|
||||
AppSettings.emojiSuggestionLocale.value,
|
||||
)
|
||||
: Localizations.localeOf(context),
|
||||
).fold(
|
||||
[],
|
||||
(emojis, category) =>
|
||||
emojis..addAll(category.emoji),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: height,
|
||||
width: height,
|
||||
alignment: Alignment.center,
|
||||
child:
|
||||
PlatformInfos.platformCanRecord &&
|
||||
!controller.sendController.text.isNotEmpty &&
|
||||
controller.editEvent == null
|
||||
? HoverBuilder(
|
||||
builder: (context, hovered) => IconButton(
|
||||
tooltip: L10n.of(context).voiceMessage,
|
||||
onPressed: hovered
|
||||
? () => recordingViewModel.startRecording(
|
||||
controller.room,
|
||||
)
|
||||
: () => ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
margin: EdgeInsets.only(
|
||||
bottom: height + 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
showCloseIcon: true,
|
||||
content: Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).longPressToRecordVoiceMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
onLongPress: () => recordingViewModel
|
||||
.startRecording(controller.room),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.bubbleColor,
|
||||
foregroundColor: theme.onBubbleColor,
|
||||
),
|
||||
icon: Icon(
|
||||
hovered ? Icons.mic : Icons.mic_none_outlined,
|
||||
),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
key: Key('send_button'),
|
||||
tooltip: L10n.of(context).send,
|
||||
onPressed: controller.send,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: theme.bubbleColor,
|
||||
foregroundColor: theme.onBubbleColor,
|
||||
),
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatAccountPicker extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const _ChatAccountPicker(this.controller);
|
||||
|
||||
void _popupMenuButtonSelected(String mxid, BuildContext context) {
|
||||
final client = Matrix.of(
|
||||
context,
|
||||
).currentBundle!.firstWhere((cl) => cl!.userID == mxid, orElse: () => null);
|
||||
if (client == null) {
|
||||
Logs().w('Attempted to switch to a non-existing client $mxid');
|
||||
return;
|
||||
}
|
||||
controller.setSendingClient(client);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final clients = controller.currentRoomBundle;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: FutureBuilder<Profile>(
|
||||
future: controller.sendingClient.fetchOwnProfile(),
|
||||
builder: (context, snapshot) => PopupMenuButton<String>(
|
||||
useRootNavigator: true,
|
||||
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
|
||||
itemBuilder: (BuildContext context) => clients
|
||||
.map(
|
||||
(client) => PopupMenuItem(
|
||||
value: client!.userID,
|
||||
child: FutureBuilder<Profile>(
|
||||
future: client.fetchOwnProfile(),
|
||||
builder: (context, snapshot) => ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name:
|
||||
snapshot.data?.displayName ??
|
||||
client.userID!.localpart,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(snapshot.data?.displayName ?? client.userID!),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Avatar(
|
||||
mxContent: snapshot.data?.avatarUrl,
|
||||
name:
|
||||
snapshot.data?.displayName ??
|
||||
Matrix.of(context).client.userID!.localpart,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
494
lib/pages/chat/chat_view.dart
Normal file
494
lib/pages/chat/chat_view.dart
Normal file
@@ -0,0 +1,494 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/chat.dart';
|
||||
import 'package:tatchat/pages/chat/chat_app_bar_list_tile.dart';
|
||||
import 'package:tatchat/pages/chat/chat_app_bar_title.dart';
|
||||
import 'package:tatchat/pages/chat/chat_event_list.dart';
|
||||
import 'package:tatchat/pages/chat/pinned_events.dart';
|
||||
import 'package:tatchat/pages/chat/reply_display.dart';
|
||||
import 'package:tatchat/utils/account_config.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/matrix_live_kit_calls/matrix_live_kit_call.dart';
|
||||
import 'package:tatchat/widgets/chat_settings_popup_menu.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:tatchat/widgets/pulsating_widget.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../utils/stream_extension.dart';
|
||||
import 'chat_emoji_picker.dart';
|
||||
import 'chat_input_row.dart';
|
||||
|
||||
enum _EventContextAction { info, report }
|
||||
|
||||
class ChatView extends StatelessWidget {
|
||||
final ChatController controller;
|
||||
|
||||
const ChatView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasActiveGroupCall = controller.room.hasActiveMatrixRtcCall;
|
||||
if (controller.room.membership == Membership.invite) {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => controller.room.join(),
|
||||
exceptionContext: ExceptionContext.joinRoom,
|
||||
);
|
||||
}
|
||||
final bottomSheetPadding = FluffyThemes.isColumnMode(context) ? 16.0 : 8.0;
|
||||
final scrollUpBannerEventId = controller.scrollUpBannerEventId;
|
||||
|
||||
final accountConfig = Matrix.of(context).client.applicationAccountConfig;
|
||||
|
||||
return PopScope(
|
||||
canPop:
|
||||
controller.selectedEvents.isEmpty &&
|
||||
!controller.showEmojiPicker &&
|
||||
controller.activeThreadId == null,
|
||||
onPopInvokedWithResult: (pop, _) async {
|
||||
if (pop) return;
|
||||
if (controller.selectedEvents.isNotEmpty) {
|
||||
controller.clearSelectedEvents();
|
||||
} else if (controller.showEmojiPicker) {
|
||||
controller.emojiPickerAction();
|
||||
} else if (controller.activeThreadId != null) {
|
||||
controller.closeThread();
|
||||
}
|
||||
},
|
||||
child: StreamBuilder(
|
||||
stream: controller.room.client.onRoomState.stream
|
||||
.where((update) => update.roomId == controller.room.id)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) => FutureBuilder(
|
||||
future: controller.loadTimelineFuture,
|
||||
builder: (BuildContext context, snapshot) {
|
||||
var appbarBottomHeight = 0.0;
|
||||
final activeThreadId = controller.activeThreadId;
|
||||
if (activeThreadId != null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
if (controller.room.pinnedEventIds.isNotEmpty &&
|
||||
activeThreadId == null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
if (scrollUpBannerEventId != null && activeThreadId == null) {
|
||||
appbarBottomHeight += ChatAppBarListTile.fixedHeight;
|
||||
}
|
||||
return Scaffold(
|
||||
key: Key('chat_page'),
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
shape: FluffyThemes.isColumnMode(context)
|
||||
? Border(
|
||||
bottom: BorderSide(color: theme.dividerColor, width: 1),
|
||||
)
|
||||
: null,
|
||||
actionsIconTheme: IconThemeData(
|
||||
color: controller.selectedEvents.isEmpty
|
||||
? null
|
||||
: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
backgroundColor: controller.selectedEvents.isEmpty
|
||||
? controller.activeThreadId != null
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.surface.withAlpha(240)
|
||||
: theme.colorScheme.tertiaryContainer,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: controller.selectMode
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.clearSelectedEvents,
|
||||
tooltip: L10n.of(context).close,
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
: activeThreadId != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.closeThread,
|
||||
tooltip: L10n.of(context).backToMainChat,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
: FluffyThemes.isColumnMode(context)
|
||||
? null
|
||||
: const Center(child: BackButton()),
|
||||
titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0,
|
||||
title: ChatAppBarTitle(controller),
|
||||
actions: [
|
||||
if (controller.selectMode) ...[
|
||||
if (controller.canEditSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: L10n.of(context).edit,
|
||||
onPressed: controller.editSelectedEventAction,
|
||||
),
|
||||
if (controller.selectedEvents.length == 1 &&
|
||||
controller.activeThreadId == null &&
|
||||
controller.room.canSendDefaultMessages)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.message_outlined),
|
||||
tooltip: L10n.of(context).replyInThread,
|
||||
onPressed: () => controller.enterThread(
|
||||
controller.selectedEvents.single.eventId,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
tooltip: L10n.of(context).copyToClipboard,
|
||||
onPressed: controller.copyEventsAction,
|
||||
),
|
||||
if (controller.canRedactSelectedEvents)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
color: theme.colorScheme.error,
|
||||
tooltip: L10n.of(context).redactMessage,
|
||||
onPressed: controller.redactEventsAction,
|
||||
),
|
||||
if (controller.selectedEvents.length == 1)
|
||||
PopupMenuButton<_EventContextAction>(
|
||||
useRootNavigator: true,
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _EventContextAction.info:
|
||||
controller.showEventInfo();
|
||||
controller.clearSelectedEvents();
|
||||
break;
|
||||
case _EventContextAction.report:
|
||||
controller.reportEventAction();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (controller.canPinSelectedEvents)
|
||||
PopupMenuItem(
|
||||
onTap: controller.pinEvent,
|
||||
value: null,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.push_pin_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).pinMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.canSaveSelectedEvent)
|
||||
PopupMenuItem(
|
||||
onTap: () =>
|
||||
controller.saveSelectedEvent(context),
|
||||
value: null,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.download_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).downloadFile),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _EventContextAction.info,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(Icons.info_outlined),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).messageInfo),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.selectedEvents.single.status.isSent)
|
||||
PopupMenuItem(
|
||||
value: _EventContextAction.report,
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.shield_outlined,
|
||||
color: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(L10n.of(context).reportMessage),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (!controller.room.isArchived) ...[
|
||||
if (hasActiveGroupCall)
|
||||
PulsatingWidget(
|
||||
color: theme.colorScheme.error,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: TextButton.icon(
|
||||
icon: Icon(switch (controller
|
||||
.room
|
||||
.activeMatrixRtcCallIntent) {
|
||||
.voice => Icons.video_call_outlined,
|
||||
_ => Icons.mic_outlined,
|
||||
}),
|
||||
label: Text(
|
||||
controller.room
|
||||
.getActiveMatrixRtcMembers()
|
||||
.length
|
||||
.toString(),
|
||||
),
|
||||
onPressed:
|
||||
controller.room.hasPermissionForMatrixRtcCall
|
||||
? controller.startOrJoinVideoCall
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (controller.room.hasPermissionForMatrixRtcCall &&
|
||||
controller.supportLiveKitCalls)
|
||||
IconButton(
|
||||
icon: Icon(Icons.call_outlined),
|
||||
onPressed: controller.startOrJoinVideoCall,
|
||||
),
|
||||
ChatSettingsPopupMenu(controller.room, true),
|
||||
],
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(appbarBottomHeight),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
PinnedEvents(controller),
|
||||
if (activeThreadId != null)
|
||||
SizedBox(
|
||||
height: ChatAppBarListTile.fixedHeight,
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
controller.scrollToEventId(activeThreadId),
|
||||
icon: const Icon(Icons.message),
|
||||
label: Text(L10n.of(context).replyInThread),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
theme.colorScheme.onSecondaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scrollUpBannerEventId != null &&
|
||||
activeThreadId == null)
|
||||
ChatAppBarListTile(
|
||||
leading: IconButton(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: L10n.of(context).close,
|
||||
onPressed: () {
|
||||
controller.discardScrollUpBannerEventId();
|
||||
controller.setReadMarker();
|
||||
},
|
||||
),
|
||||
title: L10n.of(context).jumpToLastReadMessage,
|
||||
trailing: TextButton(
|
||||
onPressed: () {
|
||||
controller.scrollToEventId(scrollUpBannerEventId);
|
||||
controller.discardScrollUpBannerEventId();
|
||||
},
|
||||
child: Text(L10n.of(context).jump),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton:
|
||||
controller.showScrollDownButton &&
|
||||
controller.selectedEvents.isEmpty
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: theme.appBarTheme.toolbarHeight ?? 56,
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
onPressed: controller.scrollDown,
|
||||
heroTag: null,
|
||||
mini: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
child: const Icon(Icons.arrow_downward_outlined),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (SizeChangedLayoutNotification notification) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => controller.updateInputBarHeight(),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
child: DropTarget(
|
||||
onDragDone: controller.onDragDone,
|
||||
onDragEntered: controller.onDragEntered,
|
||||
onDragExited: controller.onDragExited,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
if (accountConfig.wallpaperUrl != null)
|
||||
Opacity(
|
||||
opacity: accountConfig.wallpaperOpacity ?? 0.5,
|
||||
child: ImageFiltered(
|
||||
imageFilter: ui.ImageFilter.blur(
|
||||
sigmaX: accountConfig.wallpaperBlur ?? 0.0,
|
||||
sigmaY: accountConfig.wallpaperBlur ?? 0.0,
|
||||
),
|
||||
child: MxcImage(
|
||||
cacheKey: accountConfig.wallpaperUrl.toString(),
|
||||
uri: accountConfig.wallpaperUrl,
|
||||
fit: BoxFit.cover,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
isThumbnail: false,
|
||||
placeholder: (_) => Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: controller.clearSingleSelectedEvent,
|
||||
child: ChatEventList(controller: controller),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: Container(
|
||||
key: controller.inputBarKey,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.surface.withAlpha(0),
|
||||
theme.colorScheme.surface,
|
||||
theme.colorScheme.surface,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(bottomSheetPadding),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.maxTimelineWidth,
|
||||
),
|
||||
child: controller.room.isExtinct
|
||||
? ElevatedButton.icon(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
label: Text(
|
||||
L10n.of(context).enterNewChat,
|
||||
),
|
||||
onPressed: controller.goToNewRoomAction,
|
||||
)
|
||||
: controller.room.canSendDefaultMessages &&
|
||||
controller.room.membership ==
|
||||
Membership.join
|
||||
? Material(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
color:
|
||||
controller.selectedEvents.isNotEmpty
|
||||
? theme
|
||||
.colorScheme
|
||||
.tertiaryContainer
|
||||
: theme
|
||||
.colorScheme
|
||||
.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child:
|
||||
controller.room.isAbandonedDMRoom ==
|
||||
true
|
||||
? Row(
|
||||
mainAxisAlignment: .spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.all(
|
||||
16,
|
||||
),
|
||||
foregroundColor: theme
|
||||
.colorScheme
|
||||
.error,
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.archive_outlined,
|
||||
),
|
||||
onPressed:
|
||||
controller.leaveChat,
|
||||
label: Text(
|
||||
L10n.of(context).leave,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.all(
|
||||
16,
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.forum_outlined,
|
||||
),
|
||||
onPressed:
|
||||
controller.recreateChat,
|
||||
label: Text(
|
||||
L10n.of(
|
||||
context,
|
||||
).reopenChat,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
ReplyDisplay(controller),
|
||||
ChatInputRow(controller),
|
||||
ChatEmojiPicker(controller),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.dragging)
|
||||
Container(
|
||||
color: theme.scaffoldBackgroundColor.withAlpha(230),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.upload_outlined, size: 100),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
lib/pages/chat/command_hints.dart
Normal file
95
lib/pages/chat/command_hints.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
// This file is auto-generated using scripts/generate_command_hints_glue.sh.
|
||||
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
|
||||
String commandExample(String command) {
|
||||
switch (command) {
|
||||
case 'markasdm':
|
||||
case 'kick':
|
||||
case 'dm':
|
||||
case 'ban':
|
||||
case 'unban':
|
||||
case 'ignore':
|
||||
case 'unignore':
|
||||
case 'invite':
|
||||
return '/$command <matrix-id>';
|
||||
case 'html':
|
||||
case 'sendraw':
|
||||
case 'plain':
|
||||
return '/$command <message>';
|
||||
case 'op':
|
||||
return '/$command <matrix-id> <power-level>';
|
||||
default:
|
||||
return '/$command';
|
||||
}
|
||||
}
|
||||
|
||||
String commandHint(L10n l10n, String command) {
|
||||
switch (command) {
|
||||
case 'ban':
|
||||
return l10n.commandHint_ban;
|
||||
case 'clearcache':
|
||||
return l10n.commandHint_clearcache;
|
||||
case 'create':
|
||||
return l10n.commandHint_create;
|
||||
case 'discardsession':
|
||||
return l10n.commandHint_discardsession;
|
||||
case 'dm':
|
||||
return l10n.commandHint_dm;
|
||||
case 'html':
|
||||
return l10n.commandHint_html;
|
||||
case 'invite':
|
||||
return l10n.commandHint_invite;
|
||||
case 'join':
|
||||
return l10n.commandHint_join;
|
||||
case 'kick':
|
||||
return l10n.commandHint_kick;
|
||||
case 'leave':
|
||||
return l10n.commandHint_leave;
|
||||
case 'me':
|
||||
return l10n.commandHint_me;
|
||||
case 'myroomavatar':
|
||||
return l10n.commandHint_myroomavatar;
|
||||
case 'myroomnick':
|
||||
return l10n.commandHint_myroomnick;
|
||||
case 'op':
|
||||
return l10n.commandHint_op;
|
||||
case 'plain':
|
||||
return l10n.commandHint_plain;
|
||||
case 'react':
|
||||
return l10n.commandHint_react;
|
||||
case 'send':
|
||||
return l10n.commandHint_send;
|
||||
case 'unban':
|
||||
return l10n.commandHint_unban;
|
||||
case 'markasdm':
|
||||
return l10n.commandHint_markasdm;
|
||||
case 'markasgroup':
|
||||
return l10n.commandHint_markasgroup;
|
||||
case 'googly':
|
||||
return l10n.commandHint_googly;
|
||||
case 'hug':
|
||||
return l10n.commandHint_hug;
|
||||
case 'cuddle':
|
||||
return l10n.commandHint_cuddle;
|
||||
case 'sendraw':
|
||||
return l10n.commandHint_sendraw;
|
||||
case 'ignore':
|
||||
return l10n.commandHint_ignore;
|
||||
case 'unignore':
|
||||
return l10n.commandHint_unignore;
|
||||
case 'roomupgrade':
|
||||
return l10n.commandHint_roomupgrade;
|
||||
case 'logout':
|
||||
return l10n.commandHint_logout;
|
||||
case 'logoutall':
|
||||
return l10n.commandHint_logoutall;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
107
lib/pages/chat/encryption_info.dart
Normal file
107
lib/pages/chat/encryption_info.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class EncryptionInfo extends StatelessWidget {
|
||||
final Room room;
|
||||
|
||||
const EncryptionInfo({super.key, required this.room});
|
||||
|
||||
Future<int> _unverifiedDevices() async {
|
||||
if (!room.encrypted) return 0;
|
||||
final users = await room.requestParticipants();
|
||||
final devicesKeysLists = users
|
||||
.map((user) => room.client.userDeviceKeys[user.id])
|
||||
.nonNulls;
|
||||
final devices = devicesKeysLists.fold<List<DeviceKeys>>(
|
||||
[],
|
||||
(devices, devicesKeysList) => [
|
||||
...devices,
|
||||
...devicesKeysList.deviceKeys.values,
|
||||
],
|
||||
);
|
||||
return devices
|
||||
.where(
|
||||
(device) =>
|
||||
!device.verified &&
|
||||
!device.blocked &&
|
||||
!device.hasValidSignatureChain(verifiedByTheirMasterKey: true) &&
|
||||
device.encryptToDevice,
|
||||
)
|
||||
.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: FutureBuilder(
|
||||
future: _unverifiedDevices(),
|
||||
builder: (context, asyncSnapshot) {
|
||||
final unverifiedDevices = asyncSnapshot.data ?? 0;
|
||||
if (unverifiedDevices == 0) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withAlpha(128),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius / 3,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Icon(Icons.lock_person_outlined, size: 13),
|
||||
),
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: L10n.of(
|
||||
context,
|
||||
).countUnverifiedDevices(unverifiedDevices),
|
||||
),
|
||||
TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.primary,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () =>
|
||||
context.go('/rooms/${room.id}/encryption'),
|
||||
text: 'Check',
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/pages/chat/event_info_dialog.dart
Normal file
108
lib/pages/chat/event_info_dialog.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/date_time_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
extension EventInfoDialogExtension on Event {
|
||||
void showInfoDialog(BuildContext context) => showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => EventInfoDialog(l10n: L10n.of(context), event: this),
|
||||
);
|
||||
}
|
||||
|
||||
class EventInfoDialog extends StatelessWidget {
|
||||
final Event event;
|
||||
final L10n l10n;
|
||||
|
||||
const EventInfoDialog({required this.event, required this.l10n, super.key});
|
||||
|
||||
String prettyJson(MatrixEvent event) {
|
||||
const decoder = JsonDecoder();
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final object = decoder.convert(jsonEncode(event.toJson()));
|
||||
return encoder.convert(object);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final originalSource = event.originalSource;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(L10n.of(context).messageInfo),
|
||||
leading: CloseButton(
|
||||
onPressed: Navigator.of(context, rootNavigator: false).pop,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Avatar(
|
||||
mxContent: event.senderFromMemoryOrFallback.avatarUrl,
|
||||
name: event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
client: event.room.client,
|
||||
presenceUserId: event.senderId,
|
||||
),
|
||||
title: Text(L10n.of(context).sender),
|
||||
subtitle: Text(
|
||||
'${event.senderFromMemoryOrFallback.calcDisplayname()} [${event.senderId}]',
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('${L10n.of(context).time}:'),
|
||||
subtitle: Text(event.originServerTs.localizedTime(context)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('${L10n.of(context).status}:'),
|
||||
subtitle: Text(event.status.name),
|
||||
),
|
||||
ListTile(title: Text('${L10n.of(context).sourceCode}:')),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SelectableText(
|
||||
prettyJson(MatrixEvent.fromJson(event.toJson())),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (event.messageType != MessageTypes.BadEncrypted &&
|
||||
originalSource != null) ...[
|
||||
ListTile(title: Text('${L10n.of(context).encrypted}:')),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SelectableText(
|
||||
prettyJson(originalSource),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
538
lib/pages/chat/events/audio_player.dart
Normal file
538
lib/pages/chat/events/audio_player.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/localized_exception_extension.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:ogg_caf_converter/ogg_caf_converter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import '../../../widgets/tatu_chat_app.dart';
|
||||
import '../../../widgets/matrix.dart';
|
||||
|
||||
class AudioPlayerWidget extends StatefulWidget {
|
||||
final Color color;
|
||||
final Color linkColor;
|
||||
final double fontSize;
|
||||
final Event event;
|
||||
|
||||
static const int wavesCount = 40;
|
||||
|
||||
const AudioPlayerWidget(
|
||||
this.event, {
|
||||
required this.color,
|
||||
required this.linkColor,
|
||||
required this.fontSize,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
AudioPlayerState createState() => AudioPlayerState();
|
||||
}
|
||||
|
||||
enum AudioPlayerStatus { notDownloaded, downloading, downloaded }
|
||||
|
||||
class AudioPlayerState extends State<AudioPlayerWidget> {
|
||||
static const double buttonSize = 36;
|
||||
|
||||
AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
|
||||
double? _downloadProgress;
|
||||
|
||||
late final MatrixState matrix;
|
||||
List<int>? _waveform;
|
||||
String? _durationString;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
final audioPlayer = matrix.voiceMessageEventId.value != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
if (audioPlayer != null) {
|
||||
if (audioPlayer.playing && !audioPlayer.isAtEndPosition) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).showMaterialBanner(
|
||||
MaterialBanner(
|
||||
padding: EdgeInsets.zero,
|
||||
leading: StreamBuilder(
|
||||
stream: audioPlayer.playerStateStream.asBroadcastStream(),
|
||||
builder: (context, _) => IconButton(
|
||||
onPressed: () {
|
||||
if (audioPlayer.isAtEndPosition) {
|
||||
audioPlayer.seek(Duration.zero);
|
||||
} else if (audioPlayer.playing) {
|
||||
audioPlayer.pause();
|
||||
} else {
|
||||
audioPlayer.play();
|
||||
}
|
||||
},
|
||||
icon: audioPlayer.playing && !audioPlayer.isAtEndPosition
|
||||
? const Icon(Icons.pause_outlined)
|
||||
: const Icon(Icons.play_arrow_outlined),
|
||||
),
|
||||
),
|
||||
content: StreamBuilder(
|
||||
stream: audioPlayer.positionStream.asBroadcastStream(),
|
||||
builder: (context, _) => GestureDetector(
|
||||
onTap: () => TatuChatApp.router.go(
|
||||
'/rooms/${widget.event.room.id}?event=${widget.event.eventId}',
|
||||
),
|
||||
child: Text(
|
||||
'🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
audioPlayer.pause();
|
||||
audioPlayer.dispose();
|
||||
matrix.voiceMessageEventId.value = matrix.audioPlayer =
|
||||
null;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(
|
||||
matrix.context,
|
||||
).clearMaterialBanners();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
audioPlayer.pause();
|
||||
audioPlayer.dispose();
|
||||
matrix.voiceMessageEventId.value = matrix.audioPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onButtonTap() async {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
|
||||
});
|
||||
final currentPlayer =
|
||||
matrix.voiceMessageEventId.value != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
if (currentPlayer != null && !currentPlayer.isAtEndPosition) {
|
||||
if (currentPlayer.playing) {
|
||||
currentPlayer.pause();
|
||||
} else {
|
||||
currentPlayer.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
matrix.voiceMessageEventId.value = widget.event.eventId;
|
||||
matrix.audioPlayer
|
||||
?..stop()
|
||||
..dispose();
|
||||
File? file;
|
||||
MatrixFile? matrixFile;
|
||||
|
||||
setState(() => status = AudioPlayerStatus.downloading);
|
||||
try {
|
||||
final fileSize = widget.event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('size');
|
||||
matrixFile = await widget.event.downloadAndDecryptAttachment(
|
||||
onDownloadProgress: fileSize != null && fileSize > 0
|
||||
? (progress) {
|
||||
final progressPercentage = progress / fileSize;
|
||||
setState(() {
|
||||
_downloadProgress = progressPercentage < 1
|
||||
? progressPercentage
|
||||
: null;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
final attachmentUrl = widget.event.attachmentOrThumbnailMxcUrl();
|
||||
|
||||
if (!kIsWeb && attachmentUrl != null) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = Uri.encodeComponent(attachmentUrl.pathSegments.last);
|
||||
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
|
||||
|
||||
await file.writeAsBytes(matrixFile.bytes);
|
||||
|
||||
if (Platform.isIOS &&
|
||||
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
|
||||
Logs().v('Convert ogg audio file for iOS...');
|
||||
final convertedFile = File('${file.path}.caf');
|
||||
if (await convertedFile.exists() == false) {
|
||||
await OggCafConverter().convertOggToCaf(
|
||||
input: file.path,
|
||||
output: convertedFile.path,
|
||||
);
|
||||
}
|
||||
file = convertedFile;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
status = AudioPlayerStatus.downloaded;
|
||||
});
|
||||
} catch (e, s) {
|
||||
Logs().v('Could not download audio file', e, s);
|
||||
if (!mounted) rethrow;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
|
||||
rethrow;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (matrix.voiceMessageEventId.value != widget.event.eventId) return;
|
||||
|
||||
final audioPlayer = matrix.audioPlayer = AudioPlayer();
|
||||
|
||||
if (file != null) {
|
||||
audioPlayer.setFilePath(file.path);
|
||||
} else {
|
||||
await audioPlayer.setAudioSource(
|
||||
AudioSource.uri(
|
||||
Uri.dataFromBytes(matrixFile.bytes, mimeType: matrixFile.mimeType),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
audioPlayer.play().onError(
|
||||
ErrorReporter(context, 'Unable to play audio message').onErrorCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleSpeed() async {
|
||||
final audioPlayer = matrix.audioPlayer;
|
||||
if (audioPlayer == null) return;
|
||||
switch (audioPlayer.speed) {
|
||||
case 1.0:
|
||||
await audioPlayer.setSpeed(1.25);
|
||||
break;
|
||||
case 1.25:
|
||||
await audioPlayer.setSpeed(1.5);
|
||||
break;
|
||||
case 1.5:
|
||||
await audioPlayer.setSpeed(2.0);
|
||||
break;
|
||||
case 2.0:
|
||||
await audioPlayer.setSpeed(0.5);
|
||||
break;
|
||||
case 0.5:
|
||||
default:
|
||||
await audioPlayer.setSpeed(1.0);
|
||||
break;
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<int>? _getWaveform() {
|
||||
final eventWaveForm = widget.event.content
|
||||
.tryGetMap<String, Object?>('org.matrix.msc1767.audio')
|
||||
?.tryGetList<int>('waveform');
|
||||
if (eventWaveForm == null || eventWaveForm.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
while (eventWaveForm.length < AudioPlayerWidget.wavesCount) {
|
||||
for (var i = 0; i < eventWaveForm.length; i = i + 2) {
|
||||
eventWaveForm.insert(i, eventWaveForm[i]);
|
||||
}
|
||||
}
|
||||
var i = 0;
|
||||
final step = (eventWaveForm.length / AudioPlayerWidget.wavesCount).round();
|
||||
while (eventWaveForm.length > AudioPlayerWidget.wavesCount) {
|
||||
eventWaveForm.removeAt(i);
|
||||
i = (i + step) % AudioPlayerWidget.wavesCount;
|
||||
}
|
||||
return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
matrix = Matrix.of(context);
|
||||
_waveform = _getWaveform();
|
||||
|
||||
if (matrix.voiceMessageEventId.value == widget.event.eventId &&
|
||||
matrix.audioPlayer != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
|
||||
});
|
||||
}
|
||||
|
||||
final durationInt = widget.event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('duration');
|
||||
if (durationInt != null) {
|
||||
final duration = Duration(milliseconds: durationInt);
|
||||
_durationString = duration.minuteSecondString;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final waveform = _waveform;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: matrix.voiceMessageEventId,
|
||||
builder: (context, eventId, _) {
|
||||
final audioPlayer = eventId != widget.event.eventId
|
||||
? null
|
||||
: matrix.audioPlayer;
|
||||
|
||||
final fileDescription = widget.event.fileDescription;
|
||||
|
||||
return StreamBuilder<Object>(
|
||||
stream: audioPlayer == null
|
||||
? null
|
||||
: StreamGroup.merge([
|
||||
audioPlayer.positionStream.asBroadcastStream(),
|
||||
audioPlayer.playerStateStream.asBroadcastStream(),
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final maxPosition =
|
||||
audioPlayer?.duration?.inMilliseconds.toDouble() ?? 1.0;
|
||||
var currentPosition =
|
||||
audioPlayer?.position.inMilliseconds.toDouble() ?? 0.0;
|
||||
if (currentPosition > maxPosition) currentPosition = maxPosition;
|
||||
|
||||
final wavePosition =
|
||||
(currentPosition / maxPosition) * AudioPlayerWidget.wavesCount;
|
||||
|
||||
final statusText = audioPlayer == null
|
||||
? _durationString ?? '00:00'
|
||||
: audioPlayer.position.minuteSecondString;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: FluffyThemes.columnWidth,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: buttonSize,
|
||||
height: buttonSize,
|
||||
child: status == AudioPlayerStatus.downloading
|
||||
? CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: widget.color,
|
||||
value: _downloadProgress,
|
||||
)
|
||||
: InkWell(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
onLongPress: () =>
|
||||
widget.event.saveFile(context),
|
||||
onTap: _onButtonTap,
|
||||
child: Material(
|
||||
color: widget.color.withAlpha(64),
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Icon(
|
||||
audioPlayer?.playing == true &&
|
||||
audioPlayer?.isAtEndPosition ==
|
||||
false
|
||||
? Icons.pause_outlined
|
||||
: Icons.play_arrow_outlined,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (waveform != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (
|
||||
var i = 0;
|
||||
i < AudioPlayerWidget.wavesCount;
|
||||
i++
|
||||
)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: i < wavePosition
|
||||
? widget.color
|
||||
: widget.color.withAlpha(
|
||||
128,
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(64),
|
||||
),
|
||||
height: 32 * (waveform[i] / 1024),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: Slider(
|
||||
thumbColor:
|
||||
widget.event.senderId ==
|
||||
widget.event.room.client.userID
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.primary,
|
||||
activeColor: waveform == null
|
||||
? widget.color
|
||||
: Colors.transparent,
|
||||
inactiveColor: waveform == null
|
||||
? widget.color.withAlpha(128)
|
||||
: Colors.transparent,
|
||||
max: maxPosition,
|
||||
value: currentPosition,
|
||||
onChanged: (position) => audioPlayer == null
|
||||
? _onButtonTap()
|
||||
: audioPlayer.seek(
|
||||
Duration(
|
||||
milliseconds: position.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
statusText,
|
||||
style: TextStyle(color: widget.color, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Icon(
|
||||
Icons.mic_none_outlined,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
secondChild: Material(
|
||||
color: widget.color.withAlpha(64),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppConfig.borderRadius,
|
||||
),
|
||||
onTap: _toggleSpeed,
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 20,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${audioPlayer?.speed}x',
|
||||
style: TextStyle(
|
||||
color: widget.color,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
crossFadeState: audioPlayer == null
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (fileDescription != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(1),
|
||||
style: TextStyle(
|
||||
color: widget.color,
|
||||
fontSize: widget.fontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: widget.linkColor,
|
||||
fontSize: widget.fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: widget.linkColor,
|
||||
),
|
||||
onOpen: (url) =>
|
||||
UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AudioPlayer {
|
||||
bool get isAtEndPosition {
|
||||
final duration = this.duration;
|
||||
if (duration == null) return true;
|
||||
return position >= duration;
|
||||
}
|
||||
}
|
||||
|
||||
extension on Duration {
|
||||
String get minuteSecondString =>
|
||||
'${inMinutes.toString().padLeft(2, '0')}:${(inSeconds % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
195
lib/pages/chat/events/cute_events.dart
Normal file
195
lib/pages/chat/events/cute_events.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class CuteContent extends StatefulWidget {
|
||||
final Event event;
|
||||
|
||||
const CuteContent(this.event, {super.key});
|
||||
|
||||
@override
|
||||
State<CuteContent> createState() => _CuteContentState();
|
||||
}
|
||||
|
||||
class _CuteContentState extends State<CuteContent> {
|
||||
static bool _isOverlayShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (AppSettings.autoplayImages.value && !_isOverlayShown) {
|
||||
addOverlay();
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<User?>(
|
||||
future: widget.event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
final label = generateLabel(snapshot.data);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: addOverlay,
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
Text(widget.event.text, style: const TextStyle(fontSize: 150)),
|
||||
if (label != null) Text(label),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addOverlay() async {
|
||||
_isOverlayShown = true;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
if (!mounted) return;
|
||||
|
||||
OverlayEntry? overlay;
|
||||
overlay = OverlayEntry(
|
||||
builder: (context) => CuteEventOverlay(
|
||||
emoji: widget.event.text,
|
||||
onAnimationEnd: () {
|
||||
_isOverlayShown = false;
|
||||
overlay?.remove();
|
||||
},
|
||||
),
|
||||
);
|
||||
Overlay.of(context).insert(overlay);
|
||||
}
|
||||
|
||||
String? generateLabel(User? user) {
|
||||
switch (widget.event.content['cute_type']) {
|
||||
case 'googly_eyes':
|
||||
return L10n.of(context).googlyEyesContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
case 'cuddle':
|
||||
return L10n.of(context).cuddleContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
case 'hug':
|
||||
return L10n.of(context).hugContent(
|
||||
user?.displayName ??
|
||||
widget.event.senderFromMemoryOrFallback.displayName ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class CuteEventOverlay extends StatefulWidget {
|
||||
final String emoji;
|
||||
final VoidCallback onAnimationEnd;
|
||||
|
||||
const CuteEventOverlay({
|
||||
super.key,
|
||||
required this.emoji,
|
||||
required this.onAnimationEnd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CuteEventOverlay> createState() => _CuteEventOverlayState();
|
||||
}
|
||||
|
||||
class _CuteEventOverlayState extends State<CuteEventOverlay>
|
||||
with TickerProviderStateMixin {
|
||||
final List<Size> items = List.generate(
|
||||
50,
|
||||
(index) => Size(Random().nextDouble(), 4 + (Random().nextDouble() * 4)),
|
||||
);
|
||||
|
||||
AnimationController? controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
vsync: this,
|
||||
);
|
||||
controller?.forward();
|
||||
controller?.addStatusListener(_hideOverlay);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller!,
|
||||
builder: (context, _) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth - _CuteOverlayContent.size;
|
||||
final height = constraints.maxHeight + _CuteOverlayContent.size;
|
||||
return SizedBox(
|
||||
height: constraints.maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
child: OverflowBox(
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomLeft,
|
||||
fit: StackFit.expand,
|
||||
children: items
|
||||
.map(
|
||||
(position) => Positioned(
|
||||
left: position.width * width,
|
||||
bottom:
|
||||
(height *
|
||||
.25 *
|
||||
position.height *
|
||||
(controller?.value ?? 0)) -
|
||||
_CuteOverlayContent.size,
|
||||
child: _CuteOverlayContent(emoji: widget.emoji),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _hideOverlay(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
widget.onAnimationEnd.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _CuteOverlayContent extends StatelessWidget {
|
||||
static const double size = 64.0;
|
||||
final String emoji;
|
||||
|
||||
const _CuteOverlayContent({required this.emoji});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: size,
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal file
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class FileSendStatusIndicator extends StatelessWidget {
|
||||
final FileSendingStatus fileSendingStatus;
|
||||
const FileSendStatusIndicator({super.key, required this.fileSendingStatus});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
switch (fileSendingStatus) {
|
||||
FileSendingStatus.generatingThumbnail => Icon(
|
||||
Icons.compress_outlined,
|
||||
size: 18,
|
||||
),
|
||||
FileSendingStatus.encrypting => Icon(Icons.lock_outlined, size: 18),
|
||||
FileSendingStatus.uploading => Icon(
|
||||
Icons.upload_outlined,
|
||||
size: 18,
|
||||
),
|
||||
},
|
||||
const CircularProgressIndicator(strokeWidth: 3),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
743
lib/pages/chat/events/html_message.dart
Normal file
743
lib/pages/chat/events/html_message.dart
Normal file
@@ -0,0 +1,743 @@
|
||||
// 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:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/utils/code_highlight_theme.dart';
|
||||
import 'package:tatchat/utils/event_checkbox_extension.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as parser;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../utils/url_launcher.dart';
|
||||
|
||||
class HtmlMessage extends StatelessWidget {
|
||||
final String html;
|
||||
final Room room;
|
||||
final Color textColor;
|
||||
final double fontSize;
|
||||
final TextStyle linkStyle;
|
||||
final void Function(LinkableElement) onOpen;
|
||||
final String? eventId;
|
||||
final Set<Event>? checkboxCheckedEvents;
|
||||
|
||||
const HtmlMessage({
|
||||
super.key,
|
||||
required this.html,
|
||||
required this.room,
|
||||
required this.fontSize,
|
||||
required this.linkStyle,
|
||||
this.textColor = Colors.black,
|
||||
required this.onOpen,
|
||||
this.eventId,
|
||||
this.checkboxCheckedEvents,
|
||||
});
|
||||
|
||||
/// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
|
||||
static const Set<String> allowedHtmlTags = {
|
||||
'font',
|
||||
'del',
|
||||
's',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'p',
|
||||
'a',
|
||||
'ul',
|
||||
'ol',
|
||||
'sup',
|
||||
'sub',
|
||||
'li',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'strong',
|
||||
'em',
|
||||
'strike',
|
||||
'code',
|
||||
'hr',
|
||||
'br',
|
||||
'div',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'caption',
|
||||
'pre',
|
||||
'span',
|
||||
'img',
|
||||
'details',
|
||||
'summary',
|
||||
// Not in the allowlist of the matrix spec yet but should be harmless:
|
||||
'ruby',
|
||||
'rp',
|
||||
'rt',
|
||||
'html',
|
||||
'body',
|
||||
};
|
||||
|
||||
static const Set<String> ignoredHtmlTags = {'mx-reply'};
|
||||
|
||||
/// We add line breaks before these tags:
|
||||
static const Set<String> blockHtmlTags = {
|
||||
'p',
|
||||
'ul',
|
||||
'ol',
|
||||
'pre',
|
||||
'div',
|
||||
'table',
|
||||
'details',
|
||||
'blockquote',
|
||||
};
|
||||
|
||||
/// We add line breaks before these tags:
|
||||
static const Set<String> fullLineHtmlTag = {
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'li',
|
||||
};
|
||||
|
||||
/// Adding line breaks before block elements.
|
||||
List<InlineSpan> _renderWithLineBreaks(
|
||||
dom.NodeList nodes,
|
||||
BuildContext context, {
|
||||
int depth = 1,
|
||||
}) {
|
||||
final onlyElements = nodes.whereType<dom.Element>().toList();
|
||||
return [
|
||||
for (var i = 0; i < nodes.length; i++) ...[
|
||||
// Actually render the node child:
|
||||
_renderHtml(nodes[i], context, depth: depth + 1),
|
||||
// Add linebreaks between blocks:
|
||||
if (nodes[i] is dom.Element &&
|
||||
onlyElements.indexOf(nodes[i] as dom.Element) <
|
||||
onlyElements.length - 1) ...[
|
||||
if (blockHtmlTags.contains((nodes[i] as dom.Element).localName))
|
||||
const TextSpan(text: '\n\n'),
|
||||
if (fullLineHtmlTag.contains((nodes[i] as dom.Element).localName))
|
||||
const TextSpan(text: '\n'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
InlineSpan _renderCodeBlockNode(dom.Node node) {
|
||||
if (node is! dom.Element) {
|
||||
return TextSpan(text: node.text);
|
||||
}
|
||||
final style =
|
||||
atomOneDarkTheme[node.className.split('-').last] ??
|
||||
atomOneDarkTheme['root'];
|
||||
|
||||
return TextSpan(
|
||||
children: node.nodes.map(_renderCodeBlockNode).toList(),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
|
||||
/// Transforms a Node to an InlineSpan.
|
||||
InlineSpan _renderHtml(dom.Node node, BuildContext context, {int depth = 1}) {
|
||||
// We must not render elements nested more than 100 elements deep:
|
||||
if (depth >= 100) return const TextSpan();
|
||||
|
||||
if (node is dom.Element &&
|
||||
ignoredHtmlTags.contains(node.localName?.toLowerCase())) {
|
||||
return const TextSpan();
|
||||
}
|
||||
|
||||
// This is a text node or not permitted node, so we render it as text:
|
||||
if (node is! dom.Element || !allowedHtmlTags.contains(node.localName)) {
|
||||
var text = node.text ?? '';
|
||||
// Single linebreak nodes between Elements are ignored:
|
||||
if (text == '\n') text = '';
|
||||
|
||||
return LinkifySpan(
|
||||
text: text,
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: linkStyle,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
}
|
||||
|
||||
switch (node.localName) {
|
||||
case 'br':
|
||||
return const TextSpan(text: '\n');
|
||||
case 'a':
|
||||
final href = node.attributes['href'];
|
||||
if (href == null) continue block;
|
||||
final matrixId = node.attributes['href']
|
||||
?.parseIdentifierIntoParts()
|
||||
?.primaryIdentifier;
|
||||
if (matrixId != null) {
|
||||
if (matrixId.sigil == '@') {
|
||||
final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
|
||||
return WidgetSpan(
|
||||
child: MatrixPill(
|
||||
key: Key('user_pill_$matrixId'),
|
||||
name: user.calcDisplayname(),
|
||||
avatar: user.avatarUrl,
|
||||
uri: href,
|
||||
outerContext: context,
|
||||
fontSize: fontSize,
|
||||
color: linkStyle.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (matrixId.sigil == '#' || matrixId.sigil == '!') {
|
||||
final room = matrixId.sigil == '!'
|
||||
? this.room.client.getRoomById(matrixId)
|
||||
: this.room.client.getRoomByAlias(matrixId);
|
||||
return WidgetSpan(
|
||||
child: MatrixPill(
|
||||
name: room?.getLocalizedDisplayname() ?? matrixId,
|
||||
avatar: room?.avatar,
|
||||
uri: href,
|
||||
outerContext: context,
|
||||
fontSize: fontSize,
|
||||
color: linkStyle.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return WidgetSpan(
|
||||
child: Tooltip(
|
||||
message: href,
|
||||
child: InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => UrlLauncher(context, href, node.text).launchUrl(),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
style: linkStyle,
|
||||
),
|
||||
style: const TextStyle(height: 1.25),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'li':
|
||||
if (!{'ol', 'ul'}.contains(node.parent?.localName)) {
|
||||
continue block;
|
||||
}
|
||||
final eventId = this.eventId;
|
||||
|
||||
final isCheckbox = node.className == 'task-list-item';
|
||||
final checkboxIndex = isCheckbox
|
||||
? node.rootElement
|
||||
.getElementsByClassName('task-list-item')
|
||||
.indexOf(node) +
|
||||
1
|
||||
: null;
|
||||
final checkedByReaction = !isCheckbox
|
||||
? null
|
||||
: checkboxCheckedEvents?.firstWhereOrNull(
|
||||
(event) => event.checkedCheckboxId == checkboxIndex,
|
||||
);
|
||||
final staticallyChecked =
|
||||
isCheckbox && node.children.first.attributes['checked'] == 'true';
|
||||
|
||||
return WidgetSpan(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: fontSize),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (!isCheckbox) ...[
|
||||
if (node.parent?.localName == 'ul')
|
||||
const TextSpan(text: '• '),
|
||||
if (node.parent?.localName == 'ol')
|
||||
TextSpan(
|
||||
text:
|
||||
'${(node.parent?.nodes.whereType<dom.Element>().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ',
|
||||
),
|
||||
],
|
||||
if (node.className == 'task-list-item')
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: SizedBox.square(
|
||||
dimension: fontSize + 2,
|
||||
child: CupertinoCheckbox(
|
||||
checkColor: textColor,
|
||||
side: BorderSide(color: textColor),
|
||||
activeColor: textColor.withAlpha(64),
|
||||
value:
|
||||
staticallyChecked || checkedByReaction != null,
|
||||
onChanged:
|
||||
eventId == null ||
|
||||
checkboxIndex == null ||
|
||||
staticallyChecked ||
|
||||
!room.canSendDefaultMessages ||
|
||||
(checkedByReaction != null &&
|
||||
checkedByReaction.senderId !=
|
||||
room.client.userID)
|
||||
? null
|
||||
: (_) => showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => checkedByReaction != null
|
||||
? room.redactEvent(
|
||||
checkedByReaction.eventId,
|
||||
)
|
||||
: room.checkCheckbox(
|
||||
eventId,
|
||||
checkboxIndex,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
],
|
||||
style: TextStyle(fontSize: fontSize, color: textColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'blockquote':
|
||||
return WidgetSpan(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: textColor, width: 5)),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'code':
|
||||
final isInline = node.parent?.localName != 'pre';
|
||||
final lang =
|
||||
node.className
|
||||
.split(' ')
|
||||
.singleWhereOrNull(
|
||||
(className) => className.startsWith('language-'),
|
||||
)
|
||||
?.split('language-')
|
||||
.last ??
|
||||
'md';
|
||||
final highlightedHtml = highlight
|
||||
.parse(node.text, language: lang)
|
||||
.toHtml();
|
||||
final element = parser.parse(highlightedHtml).body;
|
||||
if (element == null) {
|
||||
return const TextSpan(text: 'Unable to render code block!');
|
||||
}
|
||||
|
||||
return WidgetSpan(
|
||||
child: Material(
|
||||
color: atomOneBackgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: const BorderSide(color: hightlightTextColor),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Padding(
|
||||
padding: isInline
|
||||
? const EdgeInsets.symmetric(horizontal: 4.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: Text.rich(
|
||||
TextSpan(children: [_renderCodeBlockNode(element)]),
|
||||
selectionColor: hightlightTextColor.withAlpha(128),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'img':
|
||||
final mxcUrl = Uri.tryParse(node.attributes['src'] ?? '');
|
||||
if (mxcUrl == null || mxcUrl.scheme != 'mxc') {
|
||||
return TextSpan(text: node.attributes['alt']);
|
||||
}
|
||||
|
||||
final width = double.tryParse(node.attributes['width'] ?? '');
|
||||
final height = double.tryParse(node.attributes['height'] ?? '');
|
||||
const defaultDimension = 64.0;
|
||||
final actualWidth = width ?? height ?? defaultDimension;
|
||||
final actualHeight = height ?? width ?? defaultDimension;
|
||||
|
||||
return WidgetSpan(
|
||||
child: SizedBox(
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
child: MxcImage(
|
||||
uri: mxcUrl,
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
isThumbnail: (actualWidth * actualHeight) > (256 * 256),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'table':
|
||||
return WidgetSpan(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Table(
|
||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||
border: TableBorder.all(color: textColor.withAlpha(100)),
|
||||
children: node.nodes
|
||||
.whereType<dom.Element>()
|
||||
.expand(
|
||||
(e) =>
|
||||
e.localName == 'thead' ||
|
||||
e.localName == 'tbody' ||
|
||||
e.localName == 'tfoot'
|
||||
? e.nodes.whereType<dom.Element>()
|
||||
: [e],
|
||||
)
|
||||
.where((e) => e.localName == 'tr')
|
||||
.map(
|
||||
(tr) => TableRow(
|
||||
children: tr.nodes
|
||||
.whereType<dom.Element>()
|
||||
.where(
|
||||
(e) => e.localName == 'td' || e.localName == 'th',
|
||||
)
|
||||
.map(
|
||||
(cell) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
cell.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
style: cell.localName == 'th'
|
||||
? const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'thead':
|
||||
case 'tbody':
|
||||
case 'tfoot':
|
||||
case 'tr':
|
||||
case 'th':
|
||||
case 'td':
|
||||
case 'caption':
|
||||
// These are handled by the 'table' case above; if encountered
|
||||
// outside a table, render children inline as fallback.
|
||||
return TextSpan(
|
||||
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
);
|
||||
case 'hr':
|
||||
return const WidgetSpan(child: Divider());
|
||||
case 'details':
|
||||
var obscure = true;
|
||||
return WidgetSpan(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => setState(() {
|
||||
obscure = !obscure;
|
||||
}),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Icon(
|
||||
obscure ? Icons.arrow_right : Icons.arrow_drop_down,
|
||||
size: fontSize * 1.2,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
if (obscure)
|
||||
...node.nodes
|
||||
.where(
|
||||
(node) =>
|
||||
node is dom.Element &&
|
||||
node.localName == 'summary',
|
||||
)
|
||||
.map(
|
||||
(node) => _renderHtml(node, context, depth: depth),
|
||||
)
|
||||
else
|
||||
..._renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(fontSize: fontSize, color: textColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'span':
|
||||
if (!node.attributes.containsKey('data-mx-spoiler')) {
|
||||
continue block;
|
||||
}
|
||||
var obscure = true;
|
||||
return WidgetSpan(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: () => setState(() {
|
||||
obscure = !obscure;
|
||||
}),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _renderWithLineBreaks(
|
||||
node.nodes,
|
||||
context,
|
||||
depth: depth,
|
||||
),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
backgroundColor: obscure ? textColor : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
block:
|
||||
default:
|
||||
return TextSpan(
|
||||
style: switch (node.localName) {
|
||||
'body' => TextStyle(fontSize: fontSize, color: textColor),
|
||||
'a' => linkStyle,
|
||||
'strong' => const TextStyle(fontWeight: FontWeight.bold),
|
||||
'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic),
|
||||
'del' || 's' || 'strikethrough' => const TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
'u' => const TextStyle(decoration: TextDecoration.underline),
|
||||
'h1' => TextStyle(fontSize: fontSize * 1.6, height: 2),
|
||||
'h2' => TextStyle(fontSize: fontSize * 1.5, height: 2),
|
||||
'h3' => TextStyle(fontSize: fontSize * 1.4, height: 2),
|
||||
'h4' => TextStyle(fontSize: fontSize * 1.3, height: 1.75),
|
||||
'h5' => TextStyle(fontSize: fontSize * 1.2, height: 1.75),
|
||||
'h6' => TextStyle(fontSize: fontSize * 1.1, height: 1.5),
|
||||
'span' => TextStyle(
|
||||
color:
|
||||
node.attributes['color']?.hexToColor ??
|
||||
node.attributes['data-mx-color']?.hexToColor ??
|
||||
textColor,
|
||||
backgroundColor: node.attributes['data-mx-bg-color']?.hexToColor,
|
||||
),
|
||||
'sup' => const TextStyle(
|
||||
fontFeatures: [FontFeature.superscripts()],
|
||||
),
|
||||
'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]),
|
||||
_ => null,
|
||||
},
|
||||
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final element = parser.parse(html).body ?? dom.Element.html('');
|
||||
final configuredMaxLines = AppSettings.messagePreviewMaxLines.value;
|
||||
final maxLines = configuredMaxLines <= 0 ? null : configuredMaxLines;
|
||||
final span = _renderHtml(element, context);
|
||||
final style = TextStyle(fontSize: fontSize, color: textColor);
|
||||
|
||||
if (maxLines == null) {
|
||||
return Text.rich(
|
||||
span,
|
||||
style: style,
|
||||
selectionColor: textColor.withAlpha(128),
|
||||
);
|
||||
}
|
||||
|
||||
// Heuristic: if plain text has enough newlines or length, it will overflow.
|
||||
final plainText = element.text;
|
||||
final lineCount = '\n'.allMatches(plainText).length + 1;
|
||||
final likelyOverflows =
|
||||
lineCount > maxLines || plainText.length > maxLines * 50;
|
||||
|
||||
if (!likelyOverflows) {
|
||||
return Text.rich(
|
||||
span,
|
||||
style: style,
|
||||
selectionColor: textColor.withAlpha(128),
|
||||
);
|
||||
}
|
||||
|
||||
return _CollapsibleText(
|
||||
span: span,
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
textColor: textColor,
|
||||
linkColor: linkStyle.color ?? textColor,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CollapsibleText extends StatefulWidget {
|
||||
final InlineSpan span;
|
||||
final TextStyle style;
|
||||
final int maxLines;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
final double fontSize;
|
||||
|
||||
const _CollapsibleText({
|
||||
required this.span,
|
||||
required this.style,
|
||||
required this.maxLines,
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CollapsibleText> createState() => _CollapsibleTextState();
|
||||
}
|
||||
|
||||
class _CollapsibleTextState extends State<_CollapsibleText> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text.rich(
|
||||
widget.span,
|
||||
style: widget.style,
|
||||
maxLines: _expanded ? null : widget.maxLines,
|
||||
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
selectionColor: widget.textColor.withAlpha(128),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() => _expanded = !_expanded),
|
||||
style: TextButton.styleFrom(foregroundColor: widget.linkColor),
|
||||
icon: Icon(_expanded ? Icons.arrow_drop_up : Icons.arrow_drop_down),
|
||||
label: Text(_expanded ? l10n.showLess : l10n.showMore),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MatrixPill extends StatelessWidget {
|
||||
final String name;
|
||||
final BuildContext outerContext;
|
||||
final Uri? avatar;
|
||||
final String uri;
|
||||
final double? fontSize;
|
||||
final Color? color;
|
||||
|
||||
const MatrixPill({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.outerContext,
|
||||
this.avatar,
|
||||
required this.uri,
|
||||
required this.fontSize,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
splashColor: Colors.transparent,
|
||||
onTap: UrlLauncher(outerContext, uri).launchUrl,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: Avatar(mxContent: avatar, name: name, size: 16),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: name,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
decorationColor: color,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: fontSize,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
Color? get hexToColor {
|
||||
var hexCode = this;
|
||||
if (hexCode.startsWith('#')) hexCode = hexCode.substring(1);
|
||||
if (hexCode.length == 6) hexCode = 'FF$hexCode';
|
||||
final colorValue = int.tryParse(hexCode, radix: 16);
|
||||
return colorValue == null ? null : Color(colorValue);
|
||||
}
|
||||
}
|
||||
|
||||
extension on dom.Element {
|
||||
dom.Element get rootElement => parent?.rootElement ?? this;
|
||||
}
|
||||
175
lib/pages/chat/events/image_bubble.dart
Normal file
175
lib/pages/chat/events/image_bubble.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../widgets/blur_hash.dart';
|
||||
|
||||
class ImageBubble extends StatelessWidget {
|
||||
final Event event;
|
||||
final bool tapToView;
|
||||
final BoxFit fit;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final Color? linkColor;
|
||||
final bool thumbnailOnly;
|
||||
final bool animated;
|
||||
final double width;
|
||||
final double height;
|
||||
final void Function()? onTap;
|
||||
final BorderRadius? borderRadius;
|
||||
final Timeline? timeline;
|
||||
|
||||
const ImageBubble(
|
||||
this.event, {
|
||||
this.tapToView = true,
|
||||
this.backgroundColor,
|
||||
this.fit = BoxFit.contain,
|
||||
this.thumbnailOnly = true,
|
||||
this.width = 400,
|
||||
this.height = 300,
|
||||
this.animated = false,
|
||||
this.onTap,
|
||||
this.borderRadius,
|
||||
this.timeline,
|
||||
this.textColor,
|
||||
this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
var borderRadius =
|
||||
this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius);
|
||||
|
||||
final fileDescription = event.fileDescription;
|
||||
final textColor = this.textColor;
|
||||
|
||||
if (fileDescription != null) {
|
||||
borderRadius = borderRadius.copyWith(
|
||||
bottomLeft: Radius.zero,
|
||||
bottomRight: Radius.zero,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius,
|
||||
side: BorderSide(
|
||||
color: event.messageType == MessageTypes.Sticker
|
||||
? Colors.transparent
|
||||
: theme.dividerColor,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Hero(
|
||||
tag: event.eventId,
|
||||
child: AppSettings.showThumbnailsInTimeline.value
|
||||
? MxcImage(
|
||||
cacheKey: event.transactionId ?? event.eventId,
|
||||
cacheName: event.room.id,
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
animated: animated,
|
||||
isThumbnail: thumbnailOnly,
|
||||
placeholder: event.messageType == MessageTypes.Sticker
|
||||
? null
|
||||
: (_) => _ImageBubblePlaceholder(
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
)
|
||||
: _ImageBubblePlaceholder(
|
||||
event: event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileDescription != null && textColor != null)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageBubblePlaceholder extends StatelessWidget {
|
||||
final Event event;
|
||||
final double width, height;
|
||||
final BoxFit fit;
|
||||
|
||||
const _ImageBubblePlaceholder({
|
||||
required this.event,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.fit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final blurHashString =
|
||||
event.infoMap.tryGet<String>('xyz.amorgan.blurhash') ??
|
||||
'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: BlurHash(
|
||||
blurhash: blurHashString,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/pages/chat/events/map_bubble.dart
Normal file
105
lib/pages/chat/events/map_bubble.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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/l10n/l10n.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
class MapBubble extends StatelessWidget {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double zoom;
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MapBubble({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.zoom = 14.0,
|
||||
this.width = 400,
|
||||
this.height = 400,
|
||||
this.radius = 10.0,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints.loose(Size(width, height)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: width / height,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(latitude, longitude),
|
||||
initialZoom: zoom,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
maxZoom: 20,
|
||||
minZoom: 0,
|
||||
urlTemplate:
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: AppConfig.appId,
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayer(
|
||||
rotate: true,
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(latitude, longitude),
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Transform.translate(
|
||||
// No idea why the offset has to be like this, instead of -15
|
||||
// It has been determined by trying out, though, that this yields
|
||||
// the tip of the location pin to be static when zooming.
|
||||
// Might have to do with psychological perception of where the tip exactly is
|
||||
offset: const Offset(0, -12.5),
|
||||
child: const Icon(
|
||||
Icons.location_pin,
|
||||
color: Colors.red,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Text(
|
||||
' © OpenStreetMap contributors ',
|
||||
style: TextStyle(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
backgroundColor: theme.appBarTheme.backgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: Tooltip(
|
||||
message: L10n.of(context).openInMaps,
|
||||
child: InkWell(onTap: onTap, child: SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1037
lib/pages/chat/events/message.dart
Normal file
1037
lib/pages/chat/events/message.dart
Normal file
File diff suppressed because it is too large
Load Diff
321
lib/pages/chat/events/message_content.dart
Normal file
321
lib/pages/chat/events/message_content.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/events/poll.dart';
|
||||
import 'package:tatchat/pages/chat/events/video_player.dart';
|
||||
import 'package:tatchat/pages/image_viewer/image_viewer.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../config/app_config.dart';
|
||||
import '../../../utils/event_checkbox_extension.dart';
|
||||
import '../../../utils/platform_infos.dart';
|
||||
import '../../../utils/url_launcher.dart';
|
||||
import 'audio_player.dart';
|
||||
import 'cute_events.dart';
|
||||
import 'html_message.dart';
|
||||
import 'image_bubble.dart';
|
||||
import 'map_bubble.dart';
|
||||
import 'message_download_content.dart';
|
||||
|
||||
class MessageContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
final void Function(Event)? onInfoTab;
|
||||
final BorderRadius borderRadius;
|
||||
final Timeline timeline;
|
||||
final bool selected;
|
||||
final Set<String> bigEmojis;
|
||||
|
||||
const MessageContent(
|
||||
this.event, {
|
||||
this.onInfoTab,
|
||||
super.key,
|
||||
required this.timeline,
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
required this.borderRadius,
|
||||
required this.selected,
|
||||
required this.bigEmojis,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fontSize = AppConfig.messageFontSize;
|
||||
final buttonTextColor = textColor;
|
||||
switch (event.type) {
|
||||
case EventTypes.Message:
|
||||
case EventTypes.Encrypted:
|
||||
case EventTypes.Sticker:
|
||||
switch (event.messageType) {
|
||||
case MessageTypes.Image:
|
||||
case MessageTypes.Sticker:
|
||||
if (event.redacted) continue textmessage;
|
||||
final maxSize = event.messageType == MessageTypes.Sticker
|
||||
? 128.0
|
||||
: 256.0;
|
||||
final w = event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('w');
|
||||
final h = event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<int>('h');
|
||||
var width = maxSize;
|
||||
var height = maxSize;
|
||||
var fit = event.messageType == MessageTypes.Sticker
|
||||
? BoxFit.contain
|
||||
: BoxFit.cover;
|
||||
if (w != null && h != null) {
|
||||
fit = BoxFit.contain;
|
||||
if (w > h) {
|
||||
width = maxSize;
|
||||
height = max(32, maxSize * (h / w));
|
||||
} else {
|
||||
height = maxSize;
|
||||
width = max(32, maxSize * (w / h));
|
||||
}
|
||||
}
|
||||
return ImageBubble(
|
||||
event,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
borderRadius: borderRadius,
|
||||
timeline: timeline,
|
||||
textColor: textColor,
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewer(
|
||||
event,
|
||||
timeline: timeline,
|
||||
outerContext: context,
|
||||
),
|
||||
),
|
||||
);
|
||||
case CuteEventContent.eventType:
|
||||
return CuteContent(event);
|
||||
case MessageTypes.Audio:
|
||||
if (PlatformInfos.isMobile ||
|
||||
PlatformInfos.isMacOS ||
|
||||
PlatformInfos.isWeb ||
|
||||
PlatformInfos.isLinux) {
|
||||
return AudioPlayerWidget(
|
||||
event,
|
||||
color: textColor,
|
||||
linkColor: linkColor,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
return MessageDownloadContent(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case MessageTypes.Video:
|
||||
return EventVideoPlayer(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
timeline: timeline,
|
||||
);
|
||||
case MessageTypes.File:
|
||||
return MessageDownloadContent(
|
||||
event,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case MessageTypes.Location:
|
||||
final geoUri = Uri.tryParse(
|
||||
event.content.tryGet<String>('geo_uri')!,
|
||||
);
|
||||
if (geoUri != null && geoUri.scheme == 'geo') {
|
||||
final latlong = geoUri.path
|
||||
.split(';')
|
||||
.first
|
||||
.split(',')
|
||||
.map(double.tryParse)
|
||||
.toList();
|
||||
if (latlong.length == 2 &&
|
||||
latlong.first != null &&
|
||||
latlong.last != null) {
|
||||
return MapBubble(
|
||||
onTap: () =>
|
||||
UrlLauncher(context, geoUri.toString()).launchUrl(),
|
||||
latitude: latlong.first!,
|
||||
longitude: latlong.last!,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue textmessage;
|
||||
case MessageTypes.Text:
|
||||
case MessageTypes.Notice:
|
||||
case MessageTypes.Emote:
|
||||
case MessageTypes.None:
|
||||
textmessage:
|
||||
default:
|
||||
if (event.redacted) {
|
||||
return RedactionWidget(
|
||||
event: event,
|
||||
buttonTextColor: buttonTextColor,
|
||||
onInfoTab: onInfoTab,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
var html = AppSettings.renderHtml.value && event.isRichMessage
|
||||
? event.formattedText
|
||||
: event.body.replaceAll('<', '<').replaceAll('>', '>');
|
||||
if (event.messageType == MessageTypes.Emote) {
|
||||
html = '* $html';
|
||||
}
|
||||
|
||||
final bigEmotes =
|
||||
!event.isRichMessage && bigEmojis.contains(event.body);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: HtmlMessage(
|
||||
html: html,
|
||||
textColor: textColor,
|
||||
room: event.room,
|
||||
fontSize: AppConfig.messageFontSize * (bigEmotes ? 5 : 1),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
eventId: event.eventId,
|
||||
checkboxCheckedEvents: event.aggregatedEvents(
|
||||
timeline,
|
||||
EventCheckboxRoomExtension.relationshipType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
case PollEventContent.startType:
|
||||
if (event.redacted) {
|
||||
return RedactionWidget(
|
||||
event: event,
|
||||
buttonTextColor: buttonTextColor,
|
||||
onInfoTab: onInfoTab,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
}
|
||||
return PollWidget(
|
||||
event: event,
|
||||
timeline: timeline,
|
||||
textColor: textColor,
|
||||
linkColor: linkColor,
|
||||
);
|
||||
case EventTypes.CallInvite:
|
||||
return FutureBuilder<User?>(
|
||||
future: event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
return _ButtonContent(
|
||||
label: L10n.of(context).startedACall(
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
),
|
||||
icon: '📞',
|
||||
textColor: buttonTextColor,
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
default:
|
||||
return FutureBuilder<User?>(
|
||||
future: event.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
return _ButtonContent(
|
||||
label: L10n.of(context).userSentUnknownEvent(
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.senderFromMemoryOrFallback.calcDisplayname(),
|
||||
event.type,
|
||||
),
|
||||
icon: 'ℹ️',
|
||||
textColor: buttonTextColor,
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RedactionWidget extends StatelessWidget {
|
||||
const RedactionWidget({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.buttonTextColor,
|
||||
required this.onInfoTab,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
final Event event;
|
||||
final Color buttonTextColor;
|
||||
final void Function(Event p1)? onInfoTab;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<User?>(
|
||||
future: event.redactedBecause?.fetchSenderUser(),
|
||||
builder: (context, snapshot) {
|
||||
final reason = event.redactedBecause?.content.tryGet<String>('reason');
|
||||
final redactedBy =
|
||||
snapshot.data?.calcDisplayname() ??
|
||||
event.redactedBecause?.senderId.localpart ??
|
||||
L10n.of(context).user;
|
||||
return _ButtonContent(
|
||||
label: reason == null
|
||||
? L10n.of(context).redactedBy(redactedBy)
|
||||
: L10n.of(context).redactedByBecause(redactedBy, reason),
|
||||
icon: '🗑️',
|
||||
textColor: buttonTextColor.withAlpha(128),
|
||||
onPressed: () => onInfoTab!(event),
|
||||
fontSize: fontSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ButtonContent extends StatelessWidget {
|
||||
final void Function() onPressed;
|
||||
final String label;
|
||||
final String icon;
|
||||
final Color? textColor;
|
||||
final double fontSize;
|
||||
|
||||
const _ButtonContent({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.textColor,
|
||||
required this.onPressed,
|
||||
required this.fontSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
child: Text(
|
||||
'$icon $label',
|
||||
style: TextStyle(color: textColor, fontSize: fontSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/pages/chat/events/message_download_content.dart
Normal file
124
lib/pages/chat/events/message_download_content.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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/app_config.dart';
|
||||
import 'package:tatchat/pages/chat/events/file_send_status_indicator.dart';
|
||||
import 'package:tatchat/utils/file_description.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
|
||||
import 'package:tatchat/utils/url_launcher.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class MessageDownloadContent extends StatelessWidget {
|
||||
final Event event;
|
||||
final Color textColor;
|
||||
final Color linkColor;
|
||||
|
||||
const MessageDownloadContent(
|
||||
this.event, {
|
||||
required this.textColor,
|
||||
required this.linkColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filename = event.content.tryGet<String>('filename') ?? event.body;
|
||||
final filetype = (filename.contains('.')
|
||||
? filename.split('.').last.toUpperCase()
|
||||
: event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<String>('mimetype')
|
||||
?.toUpperCase() ??
|
||||
'UNKNOWN');
|
||||
final sizeString = event.sizeString ?? '?MB';
|
||||
final fileDescription = event.fileDescription;
|
||||
final fileSendingStatus = event.fileSendingStatus;
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
onTap: () => event.saveFile(context),
|
||||
child: Container(
|
||||
width: 400,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (fileSendingStatus != null)
|
||||
FileSendStatusIndicator(
|
||||
fileSendingStatus: fileSendingStatus,
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
backgroundColor: textColor.withAlpha(32),
|
||||
child: Icon(
|
||||
Icons.file_download_outlined,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$sizeString | $filetype',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: textColor, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (fileDescription != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Linkify(
|
||||
text: fileDescription,
|
||||
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
),
|
||||
options: const LinkifyOptions(humanize: false),
|
||||
linkStyle: TextStyle(
|
||||
color: linkColor,
|
||||
fontSize: AppConfig.messageFontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: linkColor,
|
||||
),
|
||||
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
208
lib/pages/chat/events/message_reactions.dart
Normal file
208
lib/pages/chat/events/message_reactions.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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' show IterableExtension;
|
||||
import 'package:tatchat/config/app_config.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.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
class MessageReactions extends StatelessWidget {
|
||||
final Event event;
|
||||
final Timeline timeline;
|
||||
|
||||
const MessageReactions(this.event, this.timeline, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allReactionEvents = event.aggregatedEvents(
|
||||
timeline,
|
||||
RelationshipTypes.reaction,
|
||||
);
|
||||
final reactionMap = <String, _ReactionEntry>{};
|
||||
final client = Matrix.of(context).client;
|
||||
|
||||
for (final e in allReactionEvents) {
|
||||
final key = e.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key');
|
||||
if (key != null) {
|
||||
if (!reactionMap.containsKey(key)) {
|
||||
reactionMap[key] = _ReactionEntry(
|
||||
key: key,
|
||||
count: 0,
|
||||
reacted: false,
|
||||
reactors: [],
|
||||
);
|
||||
}
|
||||
reactionMap[key]!.count++;
|
||||
reactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback);
|
||||
reactionMap[key]!.reacted |= e.senderId == e.room.client.userID;
|
||||
}
|
||||
}
|
||||
|
||||
final reactionList = reactionMap.values.toList();
|
||||
reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1);
|
||||
final ownMessage = event.senderId == event.room.client.userID;
|
||||
return Wrap(
|
||||
spacing: 4.0,
|
||||
runSpacing: 4.0,
|
||||
alignment: ownMessage ? WrapAlignment.end : WrapAlignment.start,
|
||||
children: [
|
||||
...reactionList.map(
|
||||
(r) => _Reaction(
|
||||
reactionKey: r.key,
|
||||
count: r.count,
|
||||
reacted: r.reacted,
|
||||
onTap: () {
|
||||
if (r.reacted) {
|
||||
final evt = allReactionEvents.firstWhereOrNull(
|
||||
(e) =>
|
||||
e.senderId == e.room.client.userID &&
|
||||
e.content.tryGetMap('m.relates_to')?['key'] == r.key,
|
||||
);
|
||||
if (evt != null) {
|
||||
showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: evt.redactEvent,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
event.room.sendReaction(event.eventId, r.key);
|
||||
}
|
||||
},
|
||||
onLongPress: () async => await _AdaptableReactorsDialog(
|
||||
client: client,
|
||||
reactionEntry: r,
|
||||
).show(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Reaction extends StatelessWidget {
|
||||
final String reactionKey;
|
||||
final int count;
|
||||
final bool? reacted;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onLongPress;
|
||||
|
||||
const _Reaction({
|
||||
required this.reactionKey,
|
||||
required this.count,
|
||||
required this.reacted,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget content;
|
||||
if (reactionKey.startsWith('mxc://')) {
|
||||
content = MxcImage(
|
||||
uri: Uri.parse(reactionKey),
|
||||
width: 20,
|
||||
height: 20,
|
||||
animated: false,
|
||||
isThumbnail: false,
|
||||
);
|
||||
} else {
|
||||
var renderKey = Characters(reactionKey);
|
||||
if (renderKey.length > 10) {
|
||||
renderKey = renderKey.getRange(0, 9) + Characters('…');
|
||||
}
|
||||
content = Text(renderKey.toString(), style: TextStyle(fontSize: 14));
|
||||
}
|
||||
return Badge(
|
||||
isLabelVisible: count > 1,
|
||||
label: Text(count.toString()),
|
||||
textStyle: TextStyle(fontSize: 10),
|
||||
textColor: theme.colorScheme.onPrimary,
|
||||
backgroundColor: theme.colorScheme.primary.withAlpha(200),
|
||||
child: InkWell(
|
||||
onTap: () => onTap != null ? onTap!() : null,
|
||||
onLongPress: () => onLongPress != null ? onLongPress!() : null,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
border: Border.all(
|
||||
color: reacted == true
|
||||
? theme.colorScheme.secondary
|
||||
: theme.colorScheme.secondaryContainer,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
|
||||
),
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionEntry {
|
||||
String key;
|
||||
int count;
|
||||
bool reacted;
|
||||
List<User>? reactors;
|
||||
|
||||
_ReactionEntry({
|
||||
required this.key,
|
||||
required this.count,
|
||||
required this.reacted,
|
||||
this.reactors,
|
||||
});
|
||||
}
|
||||
|
||||
class _AdaptableReactorsDialog extends StatelessWidget {
|
||||
final Client? client;
|
||||
final _ReactionEntry? reactionEntry;
|
||||
|
||||
const _AdaptableReactorsDialog({this.client, this.reactionEntry});
|
||||
|
||||
Future<bool?> show(BuildContext context) => showDialog(
|
||||
context: context,
|
||||
builder: (context) => this,
|
||||
barrierDismissible: true,
|
||||
useRootNavigator: false,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 4.0,
|
||||
alignment: WrapAlignment.center,
|
||||
children: <Widget>[
|
||||
for (final reactor in reactionEntry!.reactors!)
|
||||
Chip(
|
||||
avatar: Avatar(
|
||||
mxContent: reactor.avatarUrl,
|
||||
name: reactor.displayName,
|
||||
client: client,
|
||||
presenceUserId: reactor.stateKey,
|
||||
),
|
||||
label: Text(reactor.displayName!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final title = Center(child: Text(reactionEntry!.key));
|
||||
|
||||
return AlertDialog(title: title, content: body);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user