diff --git a/android/fastlane/metadata/android/en-US/full_description.txt b/android/fastlane/metadata/android/en-US/full_description.txt index e3b1bf5a9..64d6b1cef 100644 --- a/android/fastlane/metadata/android/en-US/full_description.txt +++ b/android/fastlane/metadata/android/en-US/full_description.txt @@ -1,4 +1,4 @@ -FluffyChat is an open, nonprofit and cute matrix messenger app for Ubuntu Touch, Android and iOS. +FluffyChat is an open, nonprofit and cute Matrix messenger app for Ubuntu Touch, Android and iOS. Open Opensource and open development where everyone can join. @@ -9,7 +9,7 @@ FluffyChat is donation funded. Cute ♥ Cute design and many theme settings including a dark mode. -One-to-one and groupchats +One-to-one and group chats Unlimited groups and direct chats. Easy @@ -22,11 +22,11 @@ Decentralized There is no "FluffyChat server" you are forced to use. Use the server you find trustworthy or host your own. Compatible -Compatible with Element, Fractal, Nheko and all matrix messengers. +Compatible with Element, Fractal, Nheko and all Matrix messengers. FluffyChat comes with a dream Imagine a world where everyone can choose the messenger they like and is still able to chat with all of their friends. -A world where there are no companies spying on you when you send selfies to friends and lovers. +A world where there are no companies spying on you when you send selfies to friends and your loved. And a world where apps are made for fluffyness and not for profit. ♥ diff --git a/android/fastlane/metadata/android/ru/full_description.txt b/android/fastlane/metadata/android/ru/full_description.txt new file mode 100644 index 000000000..e12c29e46 --- /dev/null +++ b/android/fastlane/metadata/android/ru/full_description.txt @@ -0,0 +1,32 @@ +FluffyChat это свободный, некоммерческий и милый чат Matrix для Ubuntu Touch, Android и iOS. + +Открыть +Открытый исходный код и открытая разработка, где присоединиться может каждый. + +Некоммерческий +FluffyChat финансируется пожертвованиями. + +Милый ♥ +Симпатичный дизайн и много настроек темы, включая тёмный режим. + +Личные и групповые чаты +Неограниченные группы и прямые чаты один на один. + +Легкий +FluffyChat сделан максимально простым в использовании. + +Бесплатный +Бесплатное использование для всех без рекламы. + +Децентрализованный +Нет единого «FluffyChat сервера» который вас принуждают использовать. Используйте сервер, который вы находите надёжным или создайте свой собственный. + +Совместимый +Совместим с Element, Fractal, Nheko и всеми мессенджерами Matrix. + + +FluffyChat стремится к мечте + +Представьте себе мир где каждый может выбрать чат который ему нравится и все еще иметь возможность общаться со всеми своими друзьями. +Мир, где нет компаний, шпионящих за тобой, когда ты посылаешь селфи друзьям и любимым. +И мир, где приложения созданы для пушистости, а не для прибыли. ♥ diff --git a/android/fastlane/metadata/android/ru/short_description.txt b/android/fastlane/metadata/android/ru/short_description.txt new file mode 100644 index 000000000..012253b60 --- /dev/null +++ b/android/fastlane/metadata/android/ru/short_description.txt @@ -0,0 +1 @@ +Общайтесь с друзьями с FluffyChat \ No newline at end of file diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 156b897b1..28a92926b 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3811,6 +3811,16 @@ "level": {} } }, + "searchIn": "Search in {chat}...", + "@searchIn": { + "type": "text", + "placeholders": { + "chat": {} + } + }, + "searchMore": "Search more...", + "gallery": "Gallery", + "files": "Files", "databaseBuildErrorBody": "Unable to build the SQlite database. The app tries to use the legacy database for now. Please report this error to the developers at {url}. The error message is: {error}", "@databaseBuildErrorBody": { "type": "text", diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 2d0bfd700..bb9f48371 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_members/chat_members.dart'; import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; import 'package:fluffychat/pages/device_settings/device_settings.dart'; import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; @@ -262,6 +263,7 @@ abstract class AppRoutes { state, ChatPage( roomId: state.pathParameters['roomid']!, + eventId: state.uri.queryParameters['event'], ), ), redirect: loggedOutRedirect, @@ -511,10 +513,22 @@ abstract class AppRoutes { ChatPage( roomId: state.pathParameters['roomid']!, shareText: state.uri.queryParameters['body'], + eventId: state.uri.queryParameters['event'], ), ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'search', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ChatSearchPage( + roomId: state.pathParameters['roomid']!, + ), + ), + redirect: loggedOutRedirect, + ), // #Pangea // GoRoute( // path: 'encryption', diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index c94eb4598..46f568373 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -37,7 +37,6 @@ import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; @@ -57,10 +56,12 @@ import 'send_location_dialog.dart'; class ChatPage extends StatelessWidget { final String roomId; final String? shareText; + final String? eventId; const ChatPage({ super.key, required this.roomId, + this.eventId, this.shareText, }); @@ -87,6 +88,7 @@ class ChatPage extends StatelessWidget { key: Key('chat_page_$roomId'), room: room, shareText: shareText, + eventId: eventId, ); } } @@ -94,11 +96,13 @@ class ChatPage extends StatelessWidget { class ChatPageWithRoom extends StatefulWidget { final Room room; final String? shareText; + final String? eventId; const ChatPageWithRoom({ super.key, required this.room, this.shareText, + this.eventId, }); @override @@ -298,12 +302,14 @@ class ChatController extends State void initState() { scrollController.addListener(_updateScrollController); inputFocus.addListener(_inputFocusListener); + _loadDraft(); super.initState(); _displayChatDetailsColumn = ValueNotifier( Matrix.of(context).store.getBool(SettingKeys.displayChatDetailsColumn) ?? false, ); + sendingClient = Matrix.of(context).client; WidgetsBinding.instance.addObserver(this); // #Pangea @@ -354,7 +360,8 @@ class ChatController extends State } void _tryLoadTimeline() async { - loadTimelineFuture = _getTimeline(); + readMarkerEventId = widget.eventId; + loadTimelineFuture = _getTimeline(eventContextId: readMarkerEventId); try { await loadTimelineFuture; final fullyRead = room.fullyRead; @@ -458,18 +465,6 @@ class ChatController extends State timeline!.requestKeys(onlineKeyBackupOnly: false); if (room.markedUnread) room.markUnread(false); - // when the scroll controller is attached we want to scroll to an event id, if specified - // and update the scroll controller...which will trigger a request history, if the - // "load more" button is visible on the screen - SchedulerBinding.instance.addPostFrameCallback((_) async { - if (mounted) { - final event = GoRouterState.of(context).uri.queryParameters['event']; - if (event != null) { - scrollToEventId(event); - } - } - }); - return; } diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 8ca40cb47..147d87f4c 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -241,6 +241,9 @@ class AudioPlayerState extends State { 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: @@ -295,7 +298,7 @@ class AudioPlayerState extends State { final statusText = this.statusText ??= _durationString ?? '00:00'; final audioPlayer = this.audioPlayer; return Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -330,80 +333,70 @@ class AudioPlayerState extends State { ), ), const SizedBox(width: 8), - Expanded( - child: Row( - children: [ - for (var i = 0; i < AudioPlayerWidget.wavesCount; i++) - Expanded( - child: GestureDetector( - onTapDown: (_) => audioPlayer?.seek( - Duration( - milliseconds: - (maxPosition / AudioPlayerWidget.wavesCount) - .round() * - i, - ), - ), - child: Container( - height: 32, - alignment: Alignment.center, - child: Opacity( - opacity: currentPosition > i ? 1 : 0.5, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: widget.color, - borderRadius: BorderRadius.circular(64), - ), - height: 32 * (waveform[i] / 1024), - ), - ), - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < AudioPlayerWidget.wavesCount; i++) + GestureDetector( + onTapDown: (_) => audioPlayer?.seek( + Duration( + milliseconds: + (maxPosition / AudioPlayerWidget.wavesCount).round() * + i, ), ), - ], - ), - ), - const SizedBox(width: 8), - Container( - alignment: Alignment.centerRight, - width: 42, - child: Text( - statusText, - style: TextStyle( - color: widget.color, - ), - ), - ), - const SizedBox(width: 4), - Stack( - children: [ - SizedBox( - width: buttonSize, - height: buttonSize, - child: InkWell( - splashColor: widget.color.withAlpha(128), - borderRadius: BorderRadius.circular(64), - onTap: audioPlayer == null ? null : _toggleSpeed, - child: Icon(Icons.mic_none_outlined, color: widget.color), - ), - ), - if (audioPlayer != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Text( - '${audioPlayer.speed.toString()}x', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 9.0, - color: widget.color, + child: Container( + height: 32, + color: widget.color.withAlpha(0), + alignment: Alignment.center, + child: Opacity( + opacity: currentPosition > i ? 1 : 0.5, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(2), + ), + width: 2, + height: 32 * (waveform[i] / 1024), + ), ), ), ), ], ), + const SizedBox(width: 8), + SizedBox( + width: 36, + child: Text( + statusText, + style: TextStyle( + color: widget.color, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Badge( + isLabelVisible: audioPlayer != null, + label: audioPlayer == null + ? null + : Text( + '${audioPlayer.speed.toString()}x', + ), + backgroundColor: Theme.of(context).colorScheme.secondary, + textColor: Theme.of(context).colorScheme.onSecondary, + child: InkWell( + splashColor: widget.color.withAlpha(128), + borderRadius: BorderRadius.circular(64), + onTap: audioPlayer == null ? null : _toggleSpeed, + child: Icon( + Icons.mic_none_outlined, + color: widget.color, + ), + ), + ), + const SizedBox(width: 8), ], ), ); diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 83d902d28..f5219b054 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -71,6 +71,7 @@ class ImageBubble extends StatelessWidget { this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); return Material( color: Colors.transparent, + clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: borderRadius, side: BorderSide( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 2d27807b8..5910c15f4 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -188,10 +188,7 @@ class MessageContent extends StatelessWidget { } return MessageDownloadContent(event, textColor); case MessageTypes.Video: - if (PlatformInfos.isMobile || PlatformInfos.isWeb) { - return EventVideoPlayer(event); - } - return MessageDownloadContent(event, textColor); + return EventVideoPlayer(event); case MessageTypes.File: return MessageDownloadContent(event, textColor); diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index 94482d527..c786af59f 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -68,7 +68,7 @@ class MessageReactions extends StatelessWidget { ); } } else { - event.room.sendReaction(event.eventId, r.key!); + event.room.sendReaction(event.eventId, r.key); } }, onLongPress: () async => await _AdaptableReactorsDialog( @@ -92,7 +92,7 @@ class MessageReactions extends StatelessWidget { } class _Reaction extends StatelessWidget { - final String? reactionKey; + final String reactionKey; final int count; final bool? reacted; final void Function()? onTap; @@ -112,16 +112,16 @@ class _Reaction extends StatelessWidget { ? Colors.white : Colors.black; final color = Theme.of(context).colorScheme.background; - final fontSize = DefaultTextStyle.of(context).style.fontSize; Widget content; - if (reactionKey!.startsWith('mxc://')) { + if (reactionKey.startsWith('mxc://')) { content = Row( mainAxisSize: MainAxisSize.min, children: [ MxcImage( - uri: Uri.parse(reactionKey!), - width: 9999, - height: fontSize, + uri: Uri.parse(reactionKey), + width: 20, + height: 20, + animated: false, ), if (count > 1) ...[ const SizedBox(width: 4), @@ -136,7 +136,7 @@ class _Reaction extends StatelessWidget { ], ); } else { - var renderKey = Characters(reactionKey!); + var renderKey = Characters(reactionKey); if (renderKey.length > 10) { renderKey = renderKey.getRange(0, 9) + Characters('…'); } @@ -171,13 +171,13 @@ class _Reaction extends StatelessWidget { } class _ReactionEntry { - String? key; + String key; int count; bool reacted; List? reactors; _ReactionEntry({ - this.key, + required this.key, required this.count, required this.reacted, this.reactors, @@ -222,7 +222,7 @@ class _AdaptableReactorsDialog extends StatelessWidget { ), ); - final title = Center(child: Text(reactionEntry!.key!)); + final title = Center(child: Text(reactionEntry!.key)); return AlertDialog.adaptive( title: title, diff --git a/lib/pages/chat/events/video_player.dart b/lib/pages/chat/events/video_player.dart index 66e50b783..2cdbbc84c 100644 --- a/lib/pages/chat/events/video_player.dart +++ b/lib/pages/chat/events/video_player.dart @@ -10,9 +10,11 @@ import 'package:path_provider/path_provider.dart'; import 'package:universal_html/html.dart' as html; import 'package:video_player/video_player.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/events/image_bubble.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/blur_hash.dart'; import '../../../utils/error_reporter.dart'; @@ -31,6 +33,10 @@ class EventVideoPlayerState extends State { File? _tmpFile; void _downloadAction() async { + if (PlatformInfos.isDesktop) { + widget.event.saveFile(context); + return; + } setState(() => _isDownloading = true); try { final videoFile = await widget.event.downloadAndDecryptAttachment(); @@ -98,6 +104,7 @@ class EventVideoPlayerState extends State { final chewieManager = _chewieManager; return Material( color: Colors.black, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), child: SizedBox( height: 300, child: chewieManager != null @@ -114,9 +121,10 @@ class EventVideoPlayerState extends State { else BlurHash(blurhash: blurHash, width: 300, height: 300), Center( - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.surface, + child: IconButton( + style: IconButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.background, ), icon: _isDownloading ? const SizedBox( @@ -126,14 +134,12 @@ class EventVideoPlayerState extends State { strokeWidth: 2, ), ) - : const Icon(Icons.download_outlined), - label: Text( - _isDownloading - ? L10n.of(context)!.loadingPleaseWait - : L10n.of(context)!.videoWithSize( - widget.event.sizeString ?? '?MB', - ), - ), + : const Icon(Icons.play_circle_outlined), + tooltip: _isDownloading + ? L10n.of(context)!.loadingPleaseWait + : L10n.of(context)!.videoWithSize( + widget.event.sizeString ?? '?MB', + ), onPressed: _isDownloading ? null : _downloadAction, ), ), diff --git a/lib/pages/chat_search/chat_search_files_tab.dart b/lib/pages/chat_search/chat_search_files_tab.dart new file mode 100644 index 000000000..85525ab6f --- /dev/null +++ b/lib/pages/chat_search/chat_search_files_tab.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; + +class ChatSearchFilesTab extends StatelessWidget { + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchFilesTab({ + required this.room, + required this.startSearch, + required this.searchStream, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: searchStream, + builder: (context, snapshot) { + final events = snapshot.data?.$1; + if (searchStream == null || events == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(strokeWidth: 2), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + + if (events.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.file_present_outlined, size: 64), + const SizedBox(height: 8), + Text(L10n.of(context)!.nothingFound), + ], + ); + } + + return SelectionArea( + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + itemCount: events.length + 1, + itemBuilder: (context, i) { + if (i == events.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: Text(L10n.of(context)!.searchMore), + ), + ), + ); + } + final event = events[i]; + final filename = event.content.tryGet('filename') ?? + event.content.tryGet('body') ?? + L10n.of(context)!.unknownEvent('File'); + final filetype = (filename.contains('.') + ? filename.split('.').last.toUpperCase() + : event.content + .tryGetMap('info') + ?.tryGet('mimetype') + ?.toUpperCase() ?? + 'UNKNOWN'); + final sizeString = event.sizeString; + final prevEvent = i > 0 ? events[i - 1] : null; + final sameEnvironment = prevEvent == null + ? false + : prevEvent.originServerTs + .sameEnvironment(event.originServerTs); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!sameEnvironment) ...[ + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + event.originServerTs.localizedTime(context), + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + ], + ), + const SizedBox(height: 4), + ], + Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + color: Theme.of(context).colorScheme.onInverseSurface, + clipBehavior: Clip.hardEdge, + child: ListTile( + leading: const Icon(Icons.file_present_outlined), + title: Text( + filename, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('$sizeString | $filetype'), + onTap: () => event.saveFile(context), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/pages/chat_search/chat_search_images_tab.dart b/lib/pages/chat_search/chat_search_images_tab.dart new file mode 100644 index 000000000..9f8c1f0c9 --- /dev/null +++ b/lib/pages/chat_search/chat_search_images_tab.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:intl/intl.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat/events/image_bubble.dart'; +import 'package:fluffychat/pages/chat/events/video_player.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; + +class ChatSearchImagesTab extends StatelessWidget { + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchImagesTab({ + required this.room, + required this.startSearch, + required this.searchStream, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: searchStream, + builder: (context, snapshot) { + final events = snapshot.data?.$1; + if (searchStream == null || events == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator.adaptive(strokeWidth: 2), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + if (events.isEmpty) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.photo_outlined, size: 64), + const SizedBox(height: 8), + Text(L10n.of(context)!.nothingFound), + ], + ); + } + final eventsByMonth = >{}; + for (final event in events) { + final month = DateTime( + event.originServerTs.year, + event.originServerTs.month, + ); + eventsByMonth[month] ??= []; + eventsByMonth[month]!.add(event); + } + final eventsByMonthList = eventsByMonth.entries.toList(); + + const padding = 8.0; + + return ListView.builder( + itemCount: eventsByMonth.length + 1, + itemBuilder: (context, i) { + if (i == eventsByMonth.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: Text(L10n.of(context)!.searchMore), + ), + ), + ); + } + + final monthEvents = eventsByMonthList[i].value; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + DateFormat.yMMMM( + Localizations.localeOf(context).languageCode, + ).format(eventsByMonthList[i].key), + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: Container( + height: 1, + color: Theme.of(context).dividerColor, + ), + ), + ], + ), + GridView.count( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + mainAxisSpacing: padding, + crossAxisSpacing: padding, + padding: const EdgeInsets.all(padding), + crossAxisCount: 3, + children: monthEvents.map( + (event) { + if (event.messageType == MessageTypes.Video) { + return EventVideoPlayer(event); + } + return ImageBubble( + event, + fit: BoxFit.cover, + ); + }, + ).toList(), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/pages/chat_search/chat_search_message_tab.dart b/lib/pages/chat_search/chat_search_message_tab.dart new file mode 100644 index 000000000..7542d6ae9 --- /dev/null +++ b/lib/pages/chat_search/chat_search_message_tab.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class ChatSearchMessageTab extends StatelessWidget { + final String searchQuery; + final Room room; + final Stream<(List, String?)>? searchStream; + final void Function({ + String? prevBatch, + List? previousSearchResult, + }) startSearch; + + const ChatSearchMessageTab({ + required this.searchQuery, + required this.room, + required this.searchStream, + required this.startSearch, + super.key, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + key: ValueKey(searchQuery), + stream: searchStream, + builder: (context, snapshot) { + if (searchStream == null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_outlined, size: 64), + const SizedBox(height: 8), + Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ), + ), + ), + ], + ); + } + final events = snapshot.data?.$1 ?? []; + + return SelectionArea( + child: ListView.separated( + itemCount: events.length + 1, + separatorBuilder: (context, _) => Divider( + color: Theme.of(context).dividerColor, + height: 1, + ), + itemBuilder: (context, i) { + if (i == events.length) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + ); + } + final nextBatch = snapshot.data?.$2; + if (nextBatch == null) { + return const SizedBox.shrink(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextButton.icon( + style: TextButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + onPressed: () => startSearch( + prevBatch: nextBatch, + previousSearchResult: events, + ), + icon: const Icon( + Icons.arrow_downward_outlined, + ), + label: Text(L10n.of(context)!.searchMore), + ), + ), + ); + } + final event = events[i]; + final sender = event.senderFromMemoryOrFallback; + final displayname = sender.calcDisplayname( + i18n: MatrixLocals(L10n.of(context)!), + ); + return _MessageSearchResultListTile( + sender: sender, + displayname: displayname, + event: event, + room: room, + ); + }, + ), + ); + }, + ); + } +} + +class _MessageSearchResultListTile extends StatelessWidget { + const _MessageSearchResultListTile({ + required this.sender, + required this.displayname, + required this.event, + required this.room, + }); + + final User sender; + final String displayname; + final Event event; + final Room room; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Row( + children: [ + Avatar( + mxContent: sender.avatarUrl, + name: displayname, + size: 16, + ), + const SizedBox(width: 8), + Text( + displayname, + ), + Expanded( + child: Text( + ' | ${event.originServerTs.localizedTimeShort(context)}', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + subtitle: Linkify( + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + text: event + .calcLocalizedBodyFallback( + plaintextBody: true, + removeMarkdown: true, + MatrixLocals( + L10n.of(context)!, + ), + ) + .trim(), + maxLines: 7, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon( + Icons.chevron_right_outlined, + ), + onPressed: () => context.go( + '/${Uri( + pathSegments: ['rooms', room.id], + queryParameters: {'event': event.eventId}, + )}', + ), + ), + ); + } +} diff --git a/lib/pages/chat_search/chat_search_page.dart b/lib/pages/chat_search/chat_search_page.dart new file mode 100644 index 000000000..c2b08d34d --- /dev/null +++ b/lib/pages/chat_search/chat_search_page.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_search/chat_search_view.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class ChatSearchPage extends StatefulWidget { + final String roomId; + const ChatSearchPage({required this.roomId, super.key}); + + @override + ChatSearchController createState() => ChatSearchController(); +} + +class ChatSearchController extends State + with SingleTickerProviderStateMixin { + Room? get room => Matrix.of(context).client.getRoomById(widget.roomId); + + final TextEditingController searchController = TextEditingController(); + late final TabController tabController; + + Timeline? timeline; + + Stream<(List, String?)>? searchStream; + Stream<(List, String?)>? galleryStream; + Stream<(List, String?)>? fileStream; + + void restartSearch() { + if (searchController.text.isEmpty) { + setState(() { + searchStream = null; + }); + return; + } + setState(() { + searchStream = const Stream.empty(); + }); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + startMessageSearch(); + }); + } + + void startMessageSearch({ + String? prevBatch, + List? previousSearchResult, + }) async { + final timeline = this.timeline ??= await room!.getTimeline(); + + if (tabController.index == 0 && searchController.text.isEmpty) { + return; + } + + setState(() { + searchStream = timeline + .startSearch( + searchTerm: searchController.text, + prevBatch: prevBatch, + requestHistoryCount: 1000, + limit: 32, + ) + .map( + (result) => ( + [ + if (previousSearchResult != null) ...previousSearchResult, + ...result.$1, + ], + result.$2, + ), + ) + .asBroadcastStream(); + }); + } + + void startGallerySearch({ + String? prevBatch, + List? previousSearchResult, + }) async { + final timeline = this.timeline ??= await room!.getTimeline(); + + setState(() { + galleryStream = timeline + .startSearch( + searchFunc: (event) => { + MessageTypes.Image, + MessageTypes.Video, + }.contains(event.messageType), + prevBatch: prevBatch, + requestHistoryCount: 1000, + limit: 32, + ) + .map( + (result) => ( + [ + if (previousSearchResult != null) ...previousSearchResult, + ...result.$1, + ], + result.$2, + ), + ) + .asBroadcastStream(); + }); + } + + void startFileSearch({ + String? prevBatch, + List? previousSearchResult, + }) async { + final timeline = this.timeline ??= await room!.getTimeline(); + + setState(() { + fileStream = timeline + .startSearch( + searchFunc: (event) => + event.messageType == MessageTypes.File || + (event.messageType == MessageTypes.Audio && + !event.content.containsKey('org.matrix.msc3245.voice')), + prevBatch: prevBatch, + requestHistoryCount: 1000, + limit: 32, + ) + .map( + (result) => ( + [ + if (previousSearchResult != null) ...previousSearchResult, + ...result.$1, + ], + result.$2, + ), + ) + .asBroadcastStream(); + }); + } + + void _onTabChanged() { + switch (tabController.index) { + case 1: + startGallerySearch(); + break; + case 2: + startFileSearch(); + break; + default: + restartSearch(); + break; + } + } + + @override + void initState() { + super.initState(); + tabController = TabController(initialIndex: 0, length: 3, vsync: this); + tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + tabController.removeListener(_onTabChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChatSearchView(this); +} diff --git a/lib/pages/chat_search/chat_search_view.dart b/lib/pages/chat_search/chat_search_view.dart new file mode 100644 index 000000000..e08f25a2b --- /dev/null +++ b/lib/pages/chat_search/chat_search_view.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_files_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_images_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_message_tab.dart'; +import 'package:fluffychat/pages/chat_search/chat_search_page.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class ChatSearchView extends StatelessWidget { + final ChatSearchController controller; + + const ChatSearchView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final room = controller.room; + if (room == null) { + return Scaffold( + appBar: AppBar(title: Text(L10n.of(context)!.oopsSomethingWentWrong)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: + Text(L10n.of(context)!.youAreNoLongerParticipatingInThisChat), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const Center(child: BackButton()), + titleSpacing: 0, + title: Text( + L10n.of(context)!.searchIn( + room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + ), + ), + ), + body: MaxWidthBody( + withScrolling: false, + child: Column( + children: [ + if (FluffyThemes.isThreeColumnMode(context)) + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: TextField( + controller: controller.searchController, + onSubmitted: (_) => controller.restartSearch(), + autofocus: true, + enabled: controller.tabController.index == 0, + decoration: InputDecoration( + hintText: L10n.of(context)!.search, + suffixIcon: const Icon(Icons.search_outlined), + ), + ), + ), + TabBar( + controller: controller.tabController, + tabs: [ + Tab(child: Text(L10n.of(context)!.messages)), + Tab(child: Text(L10n.of(context)!.gallery)), + Tab(child: Text(L10n.of(context)!.files)), + ], + ), + Expanded( + child: TabBarView( + controller: controller.tabController, + children: [ + ChatSearchMessageTab( + searchQuery: controller.searchController.text, + room: room, + startSearch: controller.startMessageSearch, + searchStream: controller.searchStream, + ), + ChatSearchImagesTab( + room: room, + startSearch: controller.startGallerySearch, + searchStream: controller.galleryStream, + ), + ChatSearchFilesTab( + room: room, + startSearch: controller.startFileSearch, + searchStream: controller.fileStream, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index 9b6e83270..e9bcf6bd8 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -13,6 +13,21 @@ import 'package:matrix/matrix.dart'; import 'matrix.dart'; +enum ChatPopupMenuActions { + details, + mute, + unmute, + leave, + search, + // #Pangea + archive, + downloadTxt, + downloadCsv, + downloadXlsx, + learningSettings, + // Pangea# +} + class ChatSettingsPopupMenu extends StatefulWidget { final Room room; final bool displayChatDetails; @@ -43,124 +58,18 @@ class ChatSettingsPopupMenuState extends State { // Pangea# notificationChangeSub ??= Matrix.of(context) .client - .onAccountData + .onSync .stream - .where((u) => u.type == 'm.push_rules') + .where( + (syncUpdate) => + syncUpdate.accountData?.any( + (accountData) => accountData.type == 'm.push_rules', + ) ?? + false, + ) .listen( (u) => setState(() {}), ); - final items = >[ - // #Pangea - PopupMenuItem( - value: 'learning_settings', - child: Row( - children: [ - const Icon(Icons.psychology_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.learningSettings), - ], - ), - ), - // Pangea# - widget.room.pushRuleState == PushRuleState.notify - ? PopupMenuItem( - value: 'mute', - child: Row( - children: [ - const Icon(Icons.notifications_off_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.muteChat), - ], - ), - ) - : PopupMenuItem( - value: 'unmute', - child: Row( - children: [ - const Icon(Icons.notifications_on_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.unmuteChat), - ], - ), - ), - // #Pangea - if (!widget.room.isArchived) - if (widget.room.isRoomAdmin) - PopupMenuItem( - value: 'archive', - child: Row( - children: [ - const Icon(Icons.archive_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.archive), - ], - ), - ), - // Pangea# - PopupMenuItem( - value: 'leave', - child: Row( - children: [ - // #Pangea - // const Icon(Icons.delete_outlined), - const Icon(Icons.arrow_forward), - // Pangea# - const SizedBox(width: 12), - Text(L10n.of(context)!.leave), - ], - ), - ), - // #Pangea - if (classSettings != null) - PopupMenuItem( - value: 'download txt', - child: Row( - children: [ - const Icon(Icons.download_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.downloadTxtFile), - ], - ), - ), - if (classSettings != null) - PopupMenuItem( - value: 'download csv', - child: Row( - children: [ - const Icon(Icons.download_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.downloadCSVFile), - ], - ), - ), - if (classSettings != null) - PopupMenuItem( - value: 'download xlsx', - child: Row( - children: [ - const Icon(Icons.download_outlined), - const SizedBox(width: 12), - Text(L10n.of(context)!.downloadXLSXFile), - ], - ), - ), - // Pangea# - ]; - if (widget.displayChatDetails) { - items.insert( - 0, - PopupMenuItem( - value: 'details', - child: Row( - children: [ - const Icon(Icons.info_outline_rounded), - const SizedBox(width: 12), - Text(L10n.of(context)!.chatDetails), - ], - ), - ), - ); - } return Stack( alignment: Alignment.center, children: [ @@ -175,11 +84,50 @@ class ChatSettingsPopupMenuState extends State { // child: const SizedBox.shrink(), // ), // Pangea# - PopupMenuButton( - onSelected: (String choice) async { + PopupMenuButton( + onSelected: (choice) async { switch (choice) { + case ChatPopupMenuActions.leave: + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context)!.areYouSure, + okLabel: L10n.of(context)!.ok, + cancelLabel: L10n.of(context)!.cancel, + message: L10n.of(context)!.archiveRoomDescription, + ); + if (confirmed == OkCancelResult.ok) { + final success = await showFutureLoadingDialog( + context: context, + future: () => widget.room.leave(), + ); + if (success.error == null) { + context.go('/rooms'); + } + } + break; + case ChatPopupMenuActions.mute: + await showFutureLoadingDialog( + context: context, + future: () => + widget.room.setPushRuleState(PushRuleState.mentionsOnly), + ); + break; + case ChatPopupMenuActions.unmute: + await showFutureLoadingDialog( + context: context, + future: () => + widget.room.setPushRuleState(PushRuleState.notify), + ); + break; + case ChatPopupMenuActions.details: + _showChatDetails(); + break; + case ChatPopupMenuActions.search: + context.go('/rooms/${widget.room.id}/search'); + break; // #Pangea - case 'archive': + case ChatPopupMenuActions.archive: final confirmed = await showOkCancelAlertDialog( useRootNavigator: false, context: context, @@ -198,52 +146,7 @@ class ChatSettingsPopupMenuState extends State { } } break; - // Pangea# - case 'leave': - final bool onlyAdmin = await widget.room.isOnlyAdmin(); - final confirmed = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context)!.areYouSure, - okLabel: L10n.of(context)!.ok, - cancelLabel: L10n.of(context)!.cancel, - message: onlyAdmin - ? L10n.of(context)!.onlyAdminDescription - : L10n.of(context)!.leaveRoomDescription, - ); - if (confirmed == OkCancelResult.ok) { - final success = await showFutureLoadingDialog( - context: context, - future: () => - onlyAdmin ? widget.room.archive() : widget.room.leave(), - ); - if (success.error == null) { - context.go('/rooms'); - } - } - break; - case 'mute': - await showFutureLoadingDialog( - context: context, - future: () => - widget.room.setPushRuleState(PushRuleState.mentionsOnly), - ); - break; - case 'unmute': - await showFutureLoadingDialog( - context: context, - future: () => - widget.room.setPushRuleState(PushRuleState.notify), - ); - break; - case 'todos': - context.go('/rooms/${widget.room.id}/tasks'); - break; - case 'details': - _showChatDetails(); - break; - // #Pangea - case 'download txt': + case ChatPopupMenuActions.downloadTxt: showFutureLoadingDialog( context: context, future: () => downloadChat( @@ -255,7 +158,7 @@ class ChatSettingsPopupMenuState extends State { ), ); break; - case 'download csv': + case ChatPopupMenuActions.downloadCsv: showFutureLoadingDialog( context: context, future: () => downloadChat( @@ -267,7 +170,7 @@ class ChatSettingsPopupMenuState extends State { ), ); break; - case 'download xlsx': + case ChatPopupMenuActions.downloadXlsx: showFutureLoadingDialog( context: context, future: () => downloadChat( @@ -279,13 +182,129 @@ class ChatSettingsPopupMenuState extends State { ), ); break; - case 'learning_settings': + case ChatPopupMenuActions.learningSettings: context.go('/rooms/settings/learning'); break; // Pangea# } }, - itemBuilder: (BuildContext context) => items, + itemBuilder: (BuildContext context) => [ + // #Pangea + PopupMenuItem( + value: ChatPopupMenuActions.learningSettings, + child: Row( + children: [ + const Icon(Icons.psychology_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.learningSettings), + ], + ), + ), + // Pangea# + if (widget.displayChatDetails) + PopupMenuItem( + value: ChatPopupMenuActions.details, + child: Row( + children: [ + const Icon(Icons.info_outline_rounded), + const SizedBox(width: 12), + Text(L10n.of(context)!.chatDetails), + ], + ), + ), + if (widget.room.pushRuleState == PushRuleState.notify) + PopupMenuItem( + value: ChatPopupMenuActions.mute, + child: Row( + children: [ + const Icon(Icons.notifications_off_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.muteChat), + ], + ), + ) + else + PopupMenuItem( + value: ChatPopupMenuActions.unmute, + child: Row( + children: [ + const Icon(Icons.notifications_on_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.unmuteChat), + ], + ), + ), + PopupMenuItem( + value: ChatPopupMenuActions.search, + child: Row( + children: [ + const Icon(Icons.search_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.search), + ], + ), + ), + PopupMenuItem( + value: ChatPopupMenuActions.leave, + child: Row( + children: [ + // #Pangea + // const Icon(Icons.delete_outlined), + const Icon(Icons.arrow_forward), + // Pangea# + const SizedBox(width: 12), + Text(L10n.of(context)!.leave), + ], + ), + ), + // #Pangea + if (!widget.room.isArchived) + if (widget.room.isRoomAdmin) + PopupMenuItem( + value: ChatPopupMenuActions.archive, + child: Row( + children: [ + const Icon(Icons.archive_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.archive), + ], + ), + ), + if (classSettings != null) + PopupMenuItem( + value: ChatPopupMenuActions.downloadTxt, + child: Row( + children: [ + const Icon(Icons.download_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.downloadTxtFile), + ], + ), + ), + if (classSettings != null) + PopupMenuItem( + value: ChatPopupMenuActions.downloadCsv, + child: Row( + children: [ + const Icon(Icons.download_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.downloadCSVFile), + ], + ), + ), + if (classSettings != null) + PopupMenuItem( + value: ChatPopupMenuActions.downloadXlsx, + child: Row( + children: [ + const Icon(Icons.download_outlined), + const SizedBox(width: 12), + Text(L10n.of(context)!.downloadXLSXFile), + ], + ), + ), + // Pangea# + ], ), ], ); diff --git a/needed-translations.txt b/needed-translations.txt index 49a63e171..cbd1fb0d0 100644 --- a/needed-translations.txt +++ b/needed-translations.txt @@ -769,6 +769,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -2228,6 +2232,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -3711,6 +3719,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -5198,6 +5210,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -6112,6 +6128,10 @@ "createNewAddress", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -7058,6 +7078,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -7946,6 +7970,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -9356,6 +9384,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -10492,6 +10524,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -10636,6 +10672,10 @@ "createNewAddress", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "addSpaceToSpaceDescription", "noDatabaseEncryption", "thereAreCountUsersBlocked" @@ -11411,6 +11451,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -12271,6 +12315,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -13236,6 +13284,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -14193,6 +14245,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -15506,6 +15562,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -16498,6 +16558,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -17619,6 +17683,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -18507,6 +18575,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -19719,6 +19791,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -21199,6 +21275,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -22144,6 +22224,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -23040,6 +23124,10 @@ "emptyInviteWarning", "errorGettingAudio", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -24487,6 +24575,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -25375,6 +25467,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -26591,6 +26687,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -27511,6 +27611,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "sessionLostBody", "restoreSessionBody", "signUp", @@ -28524,6 +28628,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -29865,6 +29973,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -30753,6 +30865,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -31747,6 +31863,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -32637,6 +32757,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -33795,6 +33919,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -34745,6 +34873,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -35704,6 +35836,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -37169,6 +37305,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -38057,6 +38197,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -39216,6 +39360,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -40210,6 +40358,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -41098,6 +41250,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -42323,6 +42479,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -43706,6 +43866,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -44863,6 +45027,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -45777,6 +45945,10 @@ "createNewAddress", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -47238,6 +47410,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -48676,6 +48852,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -49564,6 +49744,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -50450,6 +50634,10 @@ "createNewAddress", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -51840,6 +52028,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody", @@ -52728,6 +52920,10 @@ "clickToManageSubscription", "emptyInviteWarning", "errorGettingAudio", + "searchIn", + "searchMore", + "gallery", + "files", "signUp", "pleaseChooseAtLeastChars", "noEmailWarning", @@ -53836,6 +54032,10 @@ "initAppError", "userRole", "minimumPowerLevel", + "searchIn", + "searchMore", + "gallery", + "files", "databaseBuildErrorBody", "sessionLostBody", "restoreSessionBody",