mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-12-27 00:22:38 +01:00
26983a15a8
- support to show spaces in a list - add a beautiful animation This MR makes Spaces much easier to use on desktops and allows to better find the right space in case they have no avatar. There will be another MR builting on this work as soon as https://gitlab.com/famedly/company/frontend/libraries/matrix_api_lite/-/merge_requests/58 is merged. Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
609 lines
19 KiB
Dart
609 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
|
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
|
import 'package:snapping_sheet/snapping_sheet.dart';
|
|
import 'package:uni_links/uni_links.dart';
|
|
import 'package:vrouter/vrouter.dart';
|
|
|
|
import 'package:fluffychat/config/app_config.dart';
|
|
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
|
|
import 'package:fluffychat/pages/chat_list/spaces_bottom_bar.dart';
|
|
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
|
|
import 'package:fluffychat/utils/fluffy_share.dart';
|
|
import 'package:fluffychat/utils/platform_infos.dart';
|
|
import '../../../utils/account_bundles.dart';
|
|
import '../../main.dart';
|
|
import '../../utils/matrix_sdk_extensions.dart/matrix_file_extension.dart';
|
|
import '../../utils/url_launcher.dart';
|
|
import '../../widgets/matrix.dart';
|
|
import '../bootstrap/bootstrap_dialog.dart';
|
|
|
|
enum SelectMode { normal, share, select }
|
|
enum PopupMenuAction {
|
|
settings,
|
|
invite,
|
|
newGroup,
|
|
newSpace,
|
|
setStatus,
|
|
archive,
|
|
}
|
|
|
|
class ChatList extends StatefulWidget {
|
|
const ChatList({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
ChatListController createState() => ChatListController();
|
|
}
|
|
|
|
class ChatListController extends State<ChatList> with TickerProviderStateMixin {
|
|
StreamSubscription? _intentDataStreamSubscription;
|
|
|
|
StreamSubscription? _intentFileStreamSubscription;
|
|
|
|
StreamSubscription? _intentUriStreamSubscription;
|
|
|
|
SpacesEntry? _activeSpacesEntry;
|
|
|
|
SpacesEntry get activeSpacesEntry {
|
|
final id = _activeSpacesEntry;
|
|
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
|
|
}
|
|
|
|
BoxConstraints? snappingSheetContainerSize;
|
|
|
|
String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id;
|
|
|
|
final ScrollController scrollController = ScrollController();
|
|
bool scrolledToTop = true;
|
|
|
|
final StreamController<Client> _clientStream = StreamController.broadcast();
|
|
|
|
SnappingSheetController snappingSheetController = SnappingSheetController();
|
|
|
|
ScrollController snappingSheetScrollContentController = ScrollController();
|
|
|
|
Stream<Client> get clientStream => _clientStream.stream;
|
|
|
|
void _onScroll() {
|
|
final newScrolledToTop = scrollController.position.pixels <= 0;
|
|
if (newScrolledToTop != scrolledToTop) {
|
|
setState(() {
|
|
scrolledToTop = newScrolledToTop;
|
|
});
|
|
}
|
|
}
|
|
|
|
void setActiveSpacesEntry(BuildContext context, SpacesEntry? spaceId) {
|
|
if (snappingSheetController.currentPosition != kSpacesBottomBarHeight) {
|
|
snapBackSpacesSheet();
|
|
}
|
|
setState(() => _activeSpacesEntry = spaceId);
|
|
}
|
|
|
|
void editSpace(BuildContext context, String spaceId) async {
|
|
await Matrix.of(context).client.getRoomById(spaceId)!.postLoad();
|
|
VRouter.of(context).toSegments(['spaces', spaceId]);
|
|
}
|
|
|
|
// Needs to match GroupsSpacesEntry for 'separate group' checking.
|
|
List<Room> get spaces =>
|
|
Matrix.of(context).client.rooms.where((r) => r.isSpace).toList();
|
|
|
|
// Note that this could change due to configuration, etc.
|
|
// Also be aware that _activeSpacesEntry = null is the expected reset method.
|
|
SpacesEntry get defaultSpacesEntry => AppConfig.separateChatTypes
|
|
? DirectChatsSpacesEntry()
|
|
: AllRoomsSpacesEntry();
|
|
|
|
List<SpacesEntry> get spacesEntries {
|
|
if (AppConfig.separateChatTypes) {
|
|
return [
|
|
defaultSpacesEntry,
|
|
GroupsSpacesEntry(),
|
|
...spaces.map((space) => SpaceSpacesEntry(space)).toList()
|
|
];
|
|
}
|
|
return [
|
|
defaultSpacesEntry,
|
|
...spaces.map((space) => SpaceSpacesEntry(space)).toList()
|
|
];
|
|
}
|
|
|
|
final selectedRoomIds = <String>{};
|
|
bool? crossSigningCached;
|
|
bool showChatBackupBanner = false;
|
|
|
|
void firstRunBootstrapAction() async {
|
|
setState(() {
|
|
showChatBackupBanner = false;
|
|
});
|
|
|
|
await BootstrapDialog(
|
|
client: Matrix.of(context).client,
|
|
).show(context);
|
|
VRouter.of(context).to('/rooms');
|
|
}
|
|
|
|
String? get activeChat => VRouter.of(context).pathParameters['roomid'];
|
|
|
|
SelectMode get selectMode => Matrix.of(context).shareContent != null
|
|
? SelectMode.share
|
|
: selectedRoomIds.isEmpty
|
|
? SelectMode.normal
|
|
: SelectMode.select;
|
|
|
|
void _processIncomingSharedFiles(List<SharedMediaFile> files) {
|
|
if (files.isEmpty) return;
|
|
VRouter.of(context).to('/rooms');
|
|
final file = File(files.first.path);
|
|
|
|
Matrix.of(context).shareContent = {
|
|
'msgtype': 'chat.fluffy.shared_file',
|
|
'file': MatrixFile(
|
|
bytes: file.readAsBytesSync(),
|
|
name: file.path,
|
|
).detectFileType,
|
|
};
|
|
}
|
|
|
|
void _processIncomingSharedText(String? text) {
|
|
if (text == null) return;
|
|
VRouter.of(context).to('/rooms');
|
|
if (text.toLowerCase().startsWith(AppConfig.deepLinkPrefix) ||
|
|
text.toLowerCase().startsWith(AppConfig.inviteLinkPrefix) ||
|
|
(text.toLowerCase().startsWith(AppConfig.schemePrefix) &&
|
|
!RegExp(r'\s').hasMatch(text))) {
|
|
return _processIncomingUris(text);
|
|
}
|
|
Matrix.of(context).shareContent = {
|
|
'msgtype': 'm.text',
|
|
'body': text,
|
|
};
|
|
}
|
|
|
|
void _processIncomingUris(String? text) async {
|
|
if (text == null) return;
|
|
VRouter.of(context).to('/rooms');
|
|
WidgetsBinding.instance!.addPostFrameCallback((_) {
|
|
UrlLauncher(context, text).openMatrixToUrl();
|
|
});
|
|
}
|
|
|
|
void _initReceiveSharingIntent() {
|
|
if (!PlatformInfos.isMobile) return;
|
|
|
|
// For sharing images coming from outside the app while the app is in the memory
|
|
_intentFileStreamSubscription = ReceiveSharingIntent.getMediaStream()
|
|
.listen(_processIncomingSharedFiles, onError: print);
|
|
|
|
// For sharing images coming from outside the app while the app is closed
|
|
ReceiveSharingIntent.getInitialMedia().then(_processIncomingSharedFiles);
|
|
|
|
// For sharing or opening urls/text coming from outside the app while the app is in the memory
|
|
_intentDataStreamSubscription = ReceiveSharingIntent.getTextStream()
|
|
.listen(_processIncomingSharedText, onError: print);
|
|
|
|
// For sharing or opening urls/text coming from outside the app while the app is closed
|
|
ReceiveSharingIntent.getInitialText().then(_processIncomingSharedText);
|
|
|
|
// For receiving shared Uris
|
|
_intentUriStreamSubscription = linkStream.listen(_processIncomingUris);
|
|
if (FluffyChatApp.gotInitialLink == false) {
|
|
FluffyChatApp.gotInitialLink = true;
|
|
getInitialLink().then(_processIncomingUris);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_initReceiveSharingIntent();
|
|
|
|
scrollController.addListener(_onScroll);
|
|
_waitForFirstSync();
|
|
_hackyWebRTCFixForWeb();
|
|
super.initState();
|
|
}
|
|
|
|
void checkBootstrap() async {
|
|
if (!Matrix.of(context).client.encryptionEnabled) return;
|
|
await Matrix.of(context).client.accountDataLoading;
|
|
await Matrix.of(context).client.userDeviceKeysLoading;
|
|
final crossSigning =
|
|
await Matrix.of(context).client.encryption?.crossSigning.isCached() ??
|
|
false;
|
|
final needsBootstrap =
|
|
Matrix.of(context).client.encryption?.crossSigning.enabled == false ||
|
|
crossSigning == false;
|
|
final isUnknownSession = Matrix.of(context).client.isUnknownSession;
|
|
if (needsBootstrap || isUnknownSession) {
|
|
setState(() {
|
|
showChatBackupBanner = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_intentDataStreamSubscription?.cancel();
|
|
_intentFileStreamSubscription?.cancel();
|
|
_intentUriStreamSubscription?.cancel();
|
|
scrollController.removeListener(_onScroll);
|
|
super.dispose();
|
|
}
|
|
|
|
void toggleSelection(String roomId) {
|
|
setState(() => selectedRoomIds.contains(roomId)
|
|
? selectedRoomIds.remove(roomId)
|
|
: selectedRoomIds.add(roomId));
|
|
}
|
|
|
|
Future<void> toggleUnread() async {
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () async {
|
|
final markUnread = anySelectedRoomNotMarkedUnread;
|
|
final client = Matrix.of(context).client;
|
|
for (final roomId in selectedRoomIds) {
|
|
final room = client.getRoomById(roomId)!;
|
|
if (room.markedUnread == markUnread) continue;
|
|
await client.getRoomById(roomId)!.markUnread(markUnread);
|
|
}
|
|
},
|
|
);
|
|
cancelAction();
|
|
}
|
|
|
|
Future<void> toggleFavouriteRoom() async {
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () async {
|
|
final makeFavorite = anySelectedRoomNotFavorite;
|
|
final client = Matrix.of(context).client;
|
|
for (final roomId in selectedRoomIds) {
|
|
final room = client.getRoomById(roomId)!;
|
|
if (room.isFavourite == makeFavorite) continue;
|
|
await client.getRoomById(roomId)!.setFavourite(makeFavorite);
|
|
}
|
|
},
|
|
);
|
|
cancelAction();
|
|
}
|
|
|
|
Future<void> toggleMuted() async {
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () async {
|
|
final newState = anySelectedRoomNotMuted
|
|
? PushRuleState.mentionsOnly
|
|
: PushRuleState.notify;
|
|
final client = Matrix.of(context).client;
|
|
for (final roomId in selectedRoomIds) {
|
|
final room = client.getRoomById(roomId)!;
|
|
if (room.pushRuleState == newState) continue;
|
|
await client.getRoomById(roomId)!.setPushRuleState(newState);
|
|
}
|
|
},
|
|
);
|
|
cancelAction();
|
|
}
|
|
|
|
Future<void> archiveAction() async {
|
|
final confirmed = await showOkCancelAlertDialog(
|
|
useRootNavigator: false,
|
|
context: context,
|
|
title: L10n.of(context)!.areYouSure,
|
|
okLabel: L10n.of(context)!.yes,
|
|
cancelLabel: L10n.of(context)!.cancel,
|
|
) ==
|
|
OkCancelResult.ok;
|
|
if (!confirmed) return;
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () => _archiveSelectedRooms(),
|
|
);
|
|
setState(() {});
|
|
}
|
|
|
|
void setStatus() async {
|
|
final input = await showTextInputDialog(
|
|
useRootNavigator: false,
|
|
context: context,
|
|
title: L10n.of(context)!.setStatus,
|
|
okLabel: L10n.of(context)!.ok,
|
|
cancelLabel: L10n.of(context)!.cancel,
|
|
textFields: [
|
|
DialogTextField(
|
|
hintText: L10n.of(context)!.statusExampleMessage,
|
|
),
|
|
]);
|
|
if (input == null) return;
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () => Matrix.of(context).client.setPresence(
|
|
Matrix.of(context).client.userID!,
|
|
PresenceType.online,
|
|
statusMsg: input.single,
|
|
),
|
|
);
|
|
}
|
|
|
|
void onPopupMenuSelect(action) {
|
|
switch (action) {
|
|
case PopupMenuAction.setStatus:
|
|
setStatus();
|
|
break;
|
|
case PopupMenuAction.settings:
|
|
VRouter.of(context).to('/settings');
|
|
break;
|
|
case PopupMenuAction.invite:
|
|
FluffyShare.share(
|
|
L10n.of(context)!.inviteText(Matrix.of(context).client.userID!,
|
|
'https://matrix.to/#/${Matrix.of(context).client.userID}?client=im.fluffychat'),
|
|
context);
|
|
break;
|
|
case PopupMenuAction.newGroup:
|
|
VRouter.of(context).to('/newgroup');
|
|
break;
|
|
case PopupMenuAction.newSpace:
|
|
VRouter.of(context).to('/newspace');
|
|
break;
|
|
case PopupMenuAction.archive:
|
|
VRouter.of(context).to('/archive');
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _archiveSelectedRooms() async {
|
|
final client = Matrix.of(context).client;
|
|
while (selectedRoomIds.isNotEmpty) {
|
|
final roomId = selectedRoomIds.first;
|
|
try {
|
|
await client.getRoomById(roomId)!.leave();
|
|
} finally {
|
|
toggleSelection(roomId);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> addOrRemoveToSpace() async {
|
|
final id = activeSpaceId;
|
|
if (id != null) {
|
|
final consent = await showOkCancelAlertDialog(
|
|
context: context,
|
|
title: L10n.of(context)!.removeFromSpace,
|
|
message: L10n.of(context)!.removeFromSpaceDescription,
|
|
okLabel: L10n.of(context)!.remove,
|
|
cancelLabel: L10n.of(context)!.cancel,
|
|
isDestructiveAction: true,
|
|
fullyCapitalizedForMaterial: false,
|
|
);
|
|
if (consent != OkCancelResult.ok) return;
|
|
|
|
final space = Matrix.of(context).client.getRoomById(id);
|
|
final result = await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () async {
|
|
for (final roomId in selectedRoomIds) {
|
|
await space!.removeSpaceChild(roomId);
|
|
}
|
|
},
|
|
);
|
|
if (result.error == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(L10n.of(context)!.chatHasBeenRemovedFromThisSpace),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
final selectedSpace = await showConfirmationDialog<String>(
|
|
context: context,
|
|
title: L10n.of(context)!.addToSpace,
|
|
message: L10n.of(context)!.addToSpaceDescription,
|
|
fullyCapitalizedForMaterial: false,
|
|
actions: Matrix.of(context)
|
|
.client
|
|
.rooms
|
|
.where((r) => r.isSpace)
|
|
.map(
|
|
(space) => AlertDialogAction(
|
|
key: space.id,
|
|
label: space.displayname,
|
|
),
|
|
)
|
|
.toList());
|
|
if (selectedSpace == null) return;
|
|
final result = await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () async {
|
|
final space = Matrix.of(context).client.getRoomById(selectedSpace)!;
|
|
if (space.canSendDefaultStates) {
|
|
for (final roomId in selectedRoomIds) {
|
|
await space.setSpaceChild(roomId);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
if (result.error == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
setState(() => selectedRoomIds.clear());
|
|
}
|
|
|
|
bool get anySelectedRoomNotMarkedUnread => selectedRoomIds.any(
|
|
(roomId) => !Matrix.of(context).client.getRoomById(roomId)!.markedUnread);
|
|
|
|
bool get anySelectedRoomNotFavorite => selectedRoomIds.any(
|
|
(roomId) => !Matrix.of(context).client.getRoomById(roomId)!.isFavourite);
|
|
|
|
bool get anySelectedRoomNotMuted => selectedRoomIds.any((roomId) =>
|
|
Matrix.of(context).client.getRoomById(roomId)!.pushRuleState ==
|
|
PushRuleState.notify);
|
|
|
|
bool waitForFirstSync = false;
|
|
|
|
Future<void> _waitForFirstSync() async {
|
|
final client = Matrix.of(context).client;
|
|
await client.roomsLoading;
|
|
await client.accountDataLoading;
|
|
if (client.prevBatch?.isEmpty ?? true) {
|
|
await client.onFirstSync.stream.first;
|
|
}
|
|
// Load space members to display DM rooms
|
|
final spaceId = activeSpaceId;
|
|
if (spaceId != null) {
|
|
final space = client.getRoomById(spaceId)!;
|
|
final localMembers = space.getParticipants().length;
|
|
final actualMembersCount = (space.summary.mInvitedMemberCount ?? 0) +
|
|
(space.summary.mJoinedMemberCount ?? 0);
|
|
if (localMembers < actualMembersCount) {
|
|
await space.requestParticipants();
|
|
}
|
|
}
|
|
setState(() {
|
|
waitForFirstSync = true;
|
|
});
|
|
WidgetsBinding.instance!.addPostFrameCallback((_) => checkBootstrap());
|
|
return;
|
|
}
|
|
|
|
void cancelAction() {
|
|
if (selectMode == SelectMode.share) {
|
|
setState(() => Matrix.of(context).shareContent = null);
|
|
} else {
|
|
setState(() => selectedRoomIds.clear());
|
|
}
|
|
}
|
|
|
|
void setActiveClient(Client client) {
|
|
VRouter.of(context).to('/rooms');
|
|
setState(() {
|
|
_activeSpacesEntry = null;
|
|
snappingSheetController = SnappingSheetController();
|
|
snappingSheetScrollContentController = ScrollController();
|
|
selectedRoomIds.clear();
|
|
Matrix.of(context).setActiveClient(client);
|
|
});
|
|
_clientStream.add(client);
|
|
}
|
|
|
|
void setActiveBundle(String bundle) {
|
|
VRouter.of(context).to('/rooms');
|
|
setState(() {
|
|
_activeSpacesEntry = null;
|
|
selectedRoomIds.clear();
|
|
Matrix.of(context).activeBundle = bundle;
|
|
if (!Matrix.of(context)
|
|
.currentBundle!
|
|
.any((client) => client == Matrix.of(context).client)) {
|
|
Matrix.of(context)
|
|
.setActiveClient(Matrix.of(context).currentBundle!.first);
|
|
}
|
|
});
|
|
}
|
|
|
|
void editBundlesForAccount(String? userId, String? activeBundle) async {
|
|
final client = Matrix.of(context)
|
|
.widget
|
|
.clients[Matrix.of(context).getClientIndexByMatrixId(userId!)];
|
|
final action = await showConfirmationDialog<EditBundleAction>(
|
|
context: context,
|
|
title: L10n.of(context)!.editBundlesForAccount,
|
|
actions: [
|
|
AlertDialogAction(
|
|
key: EditBundleAction.addToBundle,
|
|
label: L10n.of(context)!.addToBundle,
|
|
),
|
|
if (activeBundle != client.userID)
|
|
AlertDialogAction(
|
|
key: EditBundleAction.removeFromBundle,
|
|
label: L10n.of(context)!.removeFromBundle,
|
|
),
|
|
],
|
|
);
|
|
if (action == null) return;
|
|
switch (action) {
|
|
case EditBundleAction.addToBundle:
|
|
final bundle = await showTextInputDialog(
|
|
context: context,
|
|
title: L10n.of(context)!.bundleName,
|
|
textFields: [
|
|
DialogTextField(hintText: L10n.of(context)!.bundleName)
|
|
]);
|
|
if (bundle == null || bundle.isEmpty || bundle.single.isEmpty) return;
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () => client.setAccountBundle(bundle.single),
|
|
);
|
|
break;
|
|
case EditBundleAction.removeFromBundle:
|
|
await showFutureLoadingDialog(
|
|
context: context,
|
|
future: () => client.removeFromAccountBundle(activeBundle!),
|
|
);
|
|
}
|
|
}
|
|
|
|
bool get displayBundles =>
|
|
Matrix.of(context).hasComplexBundles &&
|
|
Matrix.of(context).accountBundles.keys.length > 1;
|
|
|
|
String? get secureActiveBundle {
|
|
if (Matrix.of(context).activeBundle == null ||
|
|
!Matrix.of(context)
|
|
.accountBundles
|
|
.keys
|
|
.contains(Matrix.of(context).activeBundle)) {
|
|
return Matrix.of(context).accountBundles.keys.first;
|
|
}
|
|
return Matrix.of(context).activeBundle;
|
|
}
|
|
|
|
void resetActiveBundle() {
|
|
WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
|
|
setState(() {
|
|
Matrix.of(context).activeBundle = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Matrix.of(context).navigatorContext = context;
|
|
return ChatListView(this);
|
|
}
|
|
|
|
void _hackyWebRTCFixForWeb() {
|
|
Matrix.of(context).voipPlugin?.context = context;
|
|
}
|
|
|
|
void snapBackSpacesSheet() {
|
|
snappingSheetController.snapToPosition(
|
|
const SnappingPosition.pixels(
|
|
positionPixels: kSpacesBottomBarHeight,
|
|
snappingDuration: Duration(milliseconds: 500),
|
|
),
|
|
);
|
|
}
|
|
|
|
expandSpaces() {
|
|
snappingSheetController.snapToPosition(
|
|
const SnappingPosition.factor(positionFactor: 0.5),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum EditBundleAction { addToBundle, removeFromBundle }
|