Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

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

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

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

File diff suppressed because it is too large Load Diff

14
lib/l10n/intl_az.arb Normal file
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

138
lib/l10n/intl_la.arb Normal file
View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

180
lib/main.dart Normal file
View 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;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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('<', '&lt;').replaceAll('>', '&gt;');
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),
),
),
);
}
}

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

View 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