fluffychat merge

pull/1186/head
ggurdin 1 year ago
commit 540567bfe7

@ -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. ♥

@ -0,0 +1,32 @@
FluffyChat это свободный, некоммерческий и милый чат Matrix для Ubuntu Touch, Android и iOS.
Открыть
Открытый исходный код и открытая разработка, где присоединиться может каждый.
Некоммерческий
FluffyChat финансируется пожертвованиями.
Милый ♥
Симпатичный дизайн и много настроек темы, включая тёмный режим.
Личные и групповые чаты
Неограниченные группы и прямые чаты один на один.
Легкий
FluffyChat сделан максимально простым в использовании.
Бесплатный
Бесплатное использование для всех без рекламы.
Децентрализованный
Нет единого «FluffyChat сервера» который вас принуждают использовать. Используйте сервер, который вы находите надёжным или создайте свой собственный.
Совместимый
Совместим с Element, Fractal, Nheko и всеми мессенджерами Matrix.
FluffyChat стремится к мечте
Представьте себе мир где каждый может выбрать чат который ему нравится и все еще иметь возможность общаться со всеми своими друзьями.
Мир, где нет компаний, шпионящих за тобой, когда ты посылаешь селфи друзьям и любимым.
И мир, где приложения созданы для пушистости, а не для прибыли. ♥

@ -0,0 +1 @@
Общайтесь с друзьями с FluffyChat

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

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

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

@ -241,6 +241,9 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
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<AudioPlayerWidget> {
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: <Widget>[
@ -330,80 +333,70 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
),
),
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,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < AudioPlayerWidget.wavesCount; i++)
GestureDetector(
onTapDown: (_) => audioPlayer?.seek(
Duration(
milliseconds:
(maxPosition / AudioPlayerWidget.wavesCount).round() *
i,
),
),
child: Container(
height: 32,
color: widget.color.withAlpha(0),
alignment: Alignment.center,
child: Opacity(
opacity: currentPosition > i ? 1 : 0.5,
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),
),
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),
Container(
alignment: Alignment.centerRight,
width: 42,
SizedBox(
width: 36,
child: Text(
statusText,
style: TextStyle(
color: widget.color,
fontSize: 12,
),
),
),
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(
const SizedBox(width: 8),
Badge(
isLabelVisible: audioPlayer != null,
label: audioPlayer == null
? null
: Text(
'${audioPlayer.speed.toString()}x',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9.0,
color: widget.color,
),
),
),
],
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),
],
),
);

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

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

@ -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: <Widget>[
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<User>? 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,

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

@ -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<Event>, String?)>? searchStream;
final void Function({
String? prevBatch,
List<Event>? 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<String>('filename') ??
event.content.tryGet<String>('body') ??
L10n.of(context)!.unknownEvent('File');
final filetype = (filename.contains('.')
? filename.split('.').last.toUpperCase()
: event.content
.tryGetMap<String, dynamic>('info')
?.tryGet<String>('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),
),
),
],
),
);
},
),
);
},
);
}
}

@ -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<Event>, String?)>? searchStream;
final void Function({
String? prevBatch,
List<Event>? 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 = <DateTime, List<Event>>{};
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(),
),
],
);
},
);
},
);
}
}

@ -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<Event>, String?)>? searchStream;
final void Function({
String? prevBatch,
List<Event>? 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},
)}',
),
),
);
}
}

@ -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<ChatSearchPage>
with SingleTickerProviderStateMixin {
Room? get room => Matrix.of(context).client.getRoomById(widget.roomId);
final TextEditingController searchController = TextEditingController();
late final TabController tabController;
Timeline? timeline;
Stream<(List<Event>, String?)>? searchStream;
Stream<(List<Event>, String?)>? galleryStream;
Stream<(List<Event>, 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<Event>? 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<Event>? 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<Event>? 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);
}

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

@ -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<ChatSettingsPopupMenu> {
// 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 = <PopupMenuEntry<String>>[
// #Pangea
PopupMenuItem<String>(
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<String>(
value: 'mute',
child: Row(
children: [
const Icon(Icons.notifications_off_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.muteChat),
],
),
)
: PopupMenuItem<String>(
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<String>(
value: 'archive',
child: Row(
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.archive),
],
),
),
// Pangea#
PopupMenuItem<String>(
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<String>(
value: 'download txt',
child: Row(
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.downloadTxtFile),
],
),
),
if (classSettings != null)
PopupMenuItem<String>(
value: 'download csv',
child: Row(
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.downloadCSVFile),
],
),
),
if (classSettings != null)
PopupMenuItem<String>(
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<String>(
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,10 @@ class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
// child: const SizedBox.shrink(),
// ),
// Pangea#
PopupMenuButton(
onSelected: (String choice) async {
PopupMenuButton<ChatPopupMenuActions>(
onSelected: (choice) async {
switch (choice) {
// #Pangea
case 'archive':
case ChatPopupMenuActions.leave:
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
@ -191,59 +99,54 @@ class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
if (confirmed == OkCancelResult.ok) {
final success = await showFutureLoadingDialog(
context: context,
future: () => widget.room.archive(),
future: () => widget.room.leave(),
);
if (success.error == null) {
context.go('/rooms');
}
}
break;
// Pangea#
case 'leave':
final bool onlyAdmin = await widget.room.isOnlyAdmin();
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 ChatPopupMenuActions.archive:
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,
message: L10n.of(context)!.archiveRoomDescription,
);
if (confirmed == OkCancelResult.ok) {
final success = await showFutureLoadingDialog(
context: context,
future: () =>
onlyAdmin ? widget.room.archive() : widget.room.leave(),
future: () => widget.room.archive(),
);
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<ChatSettingsPopupMenu> {
),
);
break;
case 'download csv':
case ChatPopupMenuActions.downloadCsv:
showFutureLoadingDialog(
context: context,
future: () => downloadChat(
@ -267,7 +170,7 @@ class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
),
);
break;
case 'download xlsx':
case ChatPopupMenuActions.downloadXlsx:
showFutureLoadingDialog(
context: context,
future: () => downloadChat(
@ -279,13 +182,129 @@ class ChatSettingsPopupMenuState extends State<ChatSettingsPopupMenu> {
),
);
break;
case 'learning_settings':
case ChatPopupMenuActions.learningSettings:
context.go('/rooms/settings/learning');
break;
// Pangea#
}
},
itemBuilder: (BuildContext context) => items,
itemBuilder: (BuildContext context) => [
// #Pangea
PopupMenuItem<ChatPopupMenuActions>(
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<ChatPopupMenuActions>(
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<ChatPopupMenuActions>(
value: ChatPopupMenuActions.mute,
child: Row(
children: [
const Icon(Icons.notifications_off_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.muteChat),
],
),
)
else
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.unmute,
child: Row(
children: [
const Icon(Icons.notifications_on_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.unmuteChat),
],
),
),
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.search,
child: Row(
children: [
const Icon(Icons.search_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.search),
],
),
),
PopupMenuItem<ChatPopupMenuActions>(
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<ChatPopupMenuActions>(
value: ChatPopupMenuActions.archive,
child: Row(
children: [
const Icon(Icons.archive_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.archive),
],
),
),
if (classSettings != null)
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.downloadTxt,
child: Row(
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.downloadTxtFile),
],
),
),
if (classSettings != null)
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.downloadCsv,
child: Row(
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.downloadCSVFile),
],
),
),
if (classSettings != null)
PopupMenuItem<ChatPopupMenuActions>(
value: ChatPopupMenuActions.downloadXlsx,
child: Row(
children: [
const Icon(Icons.download_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.downloadXLSXFile),
],
),
),
// Pangea#
],
),
],
);

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

Loading…
Cancel
Save