feat: Groups and Direct Chats virtual spaces option

This commit is contained in:
20kdc 2022-04-03 17:00:35 +00:00 committed by Krille Fear
parent e93218d862
commit af06611efd
9 changed files with 285 additions and 87 deletions

View File

@ -2083,6 +2083,11 @@
"type": "text",
"placeholders": {}
},
"separateChatTypes": "Separate Direct Chats, Groups, and Spaces",
"@separateChatTypes": {
"type": "text",
"placeholders": {}
},
"setAProfilePicture": "Set a profile picture",
"@setAProfilePicture": {
"type": "text",

View File

@ -36,6 +36,7 @@ abstract class AppConfig {
static bool renderHtml = true;
static bool hideRedactedEvents = false;
static bool hideUnknownEvents = true;
static bool separateChatTypes = false;
static bool autoplayImages = true;
static bool sendOnEnter = false;
static bool experimentalVoip = false;

View File

@ -3,6 +3,7 @@ abstract class SettingKeys {
static const String renderHtml = 'chat.fluffy.renderHtml';
static const String hideRedactedEvents = 'chat.fluffy.hideRedactedEvents';
static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents';
static const String separateChatTypes = 'chat.fluffy.separateChatTypes';
static const String chatColor = 'chat.fluffy.chat_color';
static const String sentry = 'sentry';
static const String theme = 'theme';

View File

@ -13,8 +13,8 @@ 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_entry.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import '../../../utils/account_bundles.dart';
import '../../main.dart';
@ -47,15 +47,15 @@ class ChatListController extends State<ChatList> {
StreamSubscription? _intentUriStreamSubscription;
String? _activeSpaceId;
SpacesEntry? _activeSpacesEntry;
String? get activeSpaceId {
final id = _activeSpaceId;
return id != null && Matrix.of(context).client.getRoomById(id) == null
? null
: _activeSpaceId;
SpacesEntry get activeSpacesEntry {
final id = _activeSpacesEntry;
return (id == null || !id.stillValid(context)) ? defaultSpacesEntry : id;
}
String? get activeSpaceId => activeSpacesEntry.getSpace(context)?.id;
final ScrollController scrollController = ScrollController();
bool scrolledToTop = true;
@ -72,8 +72,8 @@ class ChatListController extends State<ChatList> {
}
}
void setActiveSpaceId(BuildContext context, String? spaceId) {
setState(() => _activeSpaceId = spaceId);
void setActiveSpacesEntry(BuildContext context, SpacesEntry spaceId) {
setState(() => _activeSpacesEntry = spaceId);
}
void editSpace(BuildContext context, String spaceId) async {
@ -81,9 +81,30 @@ class ChatListController extends State<ChatList> {
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;
@ -206,35 +227,6 @@ class ChatListController extends State<ChatList> {
super.dispose();
}
bool roomCheck(Room room) {
if (room.isSpace && room.membership == Membership.join && !room.isUnread) {
return false;
}
if (room.getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
ClientStoriesExtension.storiesRoomType) {
return false;
}
if (activeSpaceId != null) {
final space = Matrix.of(context).client.getRoomById(activeSpaceId!)!;
if (space.spaceChildren.any((child) => child.roomId == room.id)) {
return true;
}
if (room.spaceParents.any((parent) => parent.roomId == activeSpaceId)) {
return true;
}
if (room.isDirectChat &&
room.summary.mHeroes != null &&
room.summary.mHeroes!.any((userId) {
final user = space.getState(EventTypes.RoomMember, userId)?.asUser;
return user != null && user.membership == Membership.join;
})) {
return true;
}
return false;
}
return true;
}
void toggleSelection(String roomId) {
setState(() => selectedRoomIds.contains(roomId)
? selectedRoomIds.remove(roomId)
@ -370,7 +362,8 @@ class ChatListController extends State<ChatList> {
}
Future<void> addOrRemoveToSpace() async {
if (activeSpaceId != null) {
final id = activeSpaceId;
if (id != null) {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context)!.removeFromSpace,
@ -382,7 +375,7 @@ class ChatListController extends State<ChatList> {
);
if (consent != OkCancelResult.ok) return;
final space = Matrix.of(context).client.getRoomById(activeSpaceId!);
final space = Matrix.of(context).client.getRoomById(id);
final result = await showFutureLoadingDialog(
context: context,
future: () async {
@ -458,8 +451,9 @@ class ChatListController extends State<ChatList> {
await client.onFirstSync.stream.first;
}
// Load space members to display DM rooms
if (activeSpaceId != null) {
final space = client.getRoomById(activeSpaceId!)!;
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);
@ -485,7 +479,7 @@ class ChatListController extends State<ChatList> {
void setActiveClient(Client client) {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpaceId = null;
_activeSpacesEntry = null;
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client);
});
@ -495,7 +489,7 @@ class ChatListController extends State<ChatList> {
void setActiveBundle(String bundle) {
VRouter.of(context).to('/rooms');
setState(() {
_activeSpaceId = null;
_activeSpacesEntry = null;
selectedRoomIds.clear();
Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context)

View File

@ -294,11 +294,7 @@ class _ChatListViewBodyState extends State<_ChatListViewBody> {
Widget child;
if (widget.controller.waitForFirstSync &&
Matrix.of(context).client.prevBatch != null) {
final rooms = Matrix.of(context)
.client
.rooms
.where(widget.controller.roomCheck)
.toList();
final rooms = widget.controller.activeSpacesEntry.getRooms(context);
if (rooms.isEmpty) {
child = Column(
key: const ValueKey(null),
@ -323,7 +319,8 @@ class _ChatListViewBodyState extends State<_ChatListViewBody> {
],
);
} else {
final displayStoriesHeader = widget.controller.activeSpaceId == null;
final displayStoriesHeader = widget.controller.activeSpacesEntry
.shouldShowStoriesHeader(context);
child = ListView.builder(
key: ValueKey(Matrix.of(context).client.userID.toString() +
widget.controller.activeSpaceId.toString()),

View File

@ -1,10 +1,9 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:salomon_bottom_bar/salomon_bottom_bar.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -14,11 +13,9 @@ class SpacesBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final currentIndex = controller.activeSpaceId == null
? 0
: controller.spaces
.indexWhere((space) => controller.activeSpaceId == space.id) +
1;
final foundIndex = controller.spacesEntries.indexWhere(
(se) => spacesEntryRoughEquivalence(se, controller.activeSpacesEntry));
final currentIndex = foundIndex == -1 ? 0 : foundIndex;
return Material(
color: Theme.of(context).appBarTheme.backgroundColor,
elevation: 6,
@ -39,39 +36,14 @@ class SpacesBottomBar extends StatelessWidget {
child: SalomonBottomBar(
itemPadding: const EdgeInsets.all(8),
currentIndex: currentIndex,
onTap: (i) => controller.setActiveSpaceId(
onTap: (i) => controller.setActiveSpacesEntry(
context,
i == 0 ? null : controller.spaces[i - 1].id,
controller.spacesEntries[i],
),
selectedItemColor: Theme.of(context).colorScheme.primary,
items: [
SalomonBottomBarItem(
icon: const Icon(CupertinoIcons.chat_bubble_2),
activeIcon:
const Icon(CupertinoIcons.chat_bubble_2_fill),
title: Text(L10n.of(context)!.allChats),
),
...controller.spaces
.map((space) => SalomonBottomBarItem(
icon: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => controller.setActiveSpaceId(
context,
space.id,
),
onLongPress: () =>
controller.editSpace(context, space.id),
child: Avatar(
mxContent: space.avatar,
name: space.displayname,
size: 24,
fontSize: 12,
),
),
title: Text(space.displayname),
))
.toList(),
],
items: controller.spacesEntries
.map((entry) => _buildSpacesEntryUI(context, entry))
.toList(),
),
),
);
@ -79,4 +51,33 @@ class SpacesBottomBar extends StatelessWidget {
),
);
}
SalomonBottomBarItem _buildSpacesEntryUI(
BuildContext context, SpacesEntry entry) {
final space = entry.getSpace(context);
if (space != null) {
return SalomonBottomBarItem(
icon: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => controller.setActiveSpacesEntry(
context,
entry,
),
onLongPress: () => controller.editSpace(context, space.id),
child: Avatar(
mxContent: space.avatar,
name: space.displayname,
size: 24,
fontSize: 12,
),
),
title: Text(entry.getName(context)),
);
}
return SalomonBottomBarItem(
icon: entry.getIcon(false),
activeIcon: entry.getIcon(true),
title: Text(entry.getName(context)),
);
}
}

View File

@ -0,0 +1,189 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
import '../../widgets/matrix.dart';
// This is not necessarily a Space, but an abstract categorization of a room.
// More to the point, it's a selectable entry that *could* be a Space.
// Note that view code is in spaces_bottom_bar.dart because of type-specific UI.
// So only really generic functions (so far, anything ChatList cares about) go here.
// If getRoom returns something non-null, then it gets the avatar and such of a Space.
// Otherwise it gets to look like All Rooms. Future work impending.
abstract class SpacesEntry {
const SpacesEntry();
// Gets the (translated) name of this entry.
String getName(BuildContext context);
// Gets an icon for this entry (avoided if a space is given)
Icon getIcon(bool active) => active
? const Icon(CupertinoIcons.chat_bubble_2_fill)
: const Icon(CupertinoIcons.chat_bubble_2);
// If this is a specific Room, returns the space Room for various purposes.
Room? getSpace(BuildContext context) => null;
// Gets a list of rooms - this is done as part of _ChatListViewBodyState to get the full list of rooms visible from this SpacesEntry.
List<Room> getRooms(BuildContext context);
// Checks that this entry is still valid.
bool stillValid(BuildContext context) => true;
// Returns true if the Stories header should be shown.
bool shouldShowStoriesHeader(BuildContext context) => false;
}
// Common room validity checks
bool _roomCheckCommon(Room room, BuildContext context) {
if (room.isSpace && room.membership == Membership.join && !room.isUnread) {
return false;
}
if (room.getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
ClientStoriesExtension.storiesRoomType) {
return false;
}
return true;
}
bool _roomInsideSpace(Room room, Room space) {
if (space.spaceChildren.any((child) => child.roomId == room.id)) {
return true;
}
if (room.spaceParents.any((parent) => parent.roomId == space.id)) {
return true;
}
return false;
}
// "All rooms" entry.
class AllRoomsSpacesEntry extends SpacesEntry {
static final AllRoomsSpacesEntry _value = AllRoomsSpacesEntry._();
AllRoomsSpacesEntry._();
factory AllRoomsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.allChats;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => _roomCheckCommon(room, context))
.toList();
}
@override
bool shouldShowStoriesHeader(BuildContext context) => true;
}
// "Direct Chats" entry.
class DirectChatsSpacesEntry extends SpacesEntry {
static final DirectChatsSpacesEntry _value = DirectChatsSpacesEntry._();
DirectChatsSpacesEntry._();
factory DirectChatsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.directChats;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => room.isDirectChat && _roomCheckCommon(room, context))
.toList();
}
@override
bool shouldShowStoriesHeader(BuildContext context) => true;
}
// "Groups" entry.
class GroupsSpacesEntry extends SpacesEntry {
static final GroupsSpacesEntry _value = GroupsSpacesEntry._();
GroupsSpacesEntry._();
factory GroupsSpacesEntry() {
return _value;
}
@override
String getName(BuildContext context) => L10n.of(context)!.groups;
@override
Icon getIcon(bool active) =>
active ? const Icon(Icons.group) : const Icon(Icons.group_outlined);
@override
List<Room> getRooms(BuildContext context) {
final rooms = Matrix.of(context).client.rooms;
// Needs to match ChatList's definition of a space.
final spaces = rooms.where((room) => room.isSpace).toList();
return rooms
.where((room) =>
(!room.isDirectChat) &&
_roomCheckCommon(room, context) &&
separatedGroup(room, spaces))
.toList();
}
bool separatedGroup(Room room, List<Room> spaces) {
return !spaces.any((space) => _roomInsideSpace(room, space));
}
}
// All rooms associated with a specific space.
class SpaceSpacesEntry extends SpacesEntry {
final Room space;
const SpaceSpacesEntry(this.space);
@override
String getName(BuildContext context) => space.displayname;
@override
Room? getSpace(BuildContext context) => space;
@override
List<Room> getRooms(BuildContext context) {
return Matrix.of(context)
.client
.rooms
.where((room) => roomCheck(room, context))
.toList();
}
bool roomCheck(Room room, BuildContext context) {
if (!_roomCheckCommon(room, context)) {
return false;
}
if (_roomInsideSpace(room, space)) {
return true;
}
if (true) {
if (room.isDirectChat &&
room.summary.mHeroes != null &&
room.summary.mHeroes!.any((userId) {
final user = space.getState(EventTypes.RoomMember, userId)?.asUser;
return user != null && user.membership == Membership.join;
})) {
return true;
}
}
return false;
}
@override
bool stillValid(BuildContext context) =>
Matrix.of(context).client.getRoomById(space.id) != null;
}
// Produces a "rough equivalence" for maintaining the current spaces index.
bool spacesEntryRoughEquivalence(SpacesEntry a, SpacesEntry b) {
if ((a is SpaceSpacesEntry) && (b is SpaceSpacesEntry)) {
return a.space.id == b.space.id;
}
return a == b;
}

View File

@ -76,6 +76,13 @@ class SettingsChatView extends StatelessWidget {
child: Icon(Icons.insert_emoticon_outlined),
),
),
const Divider(height: 1),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.separateChatTypes,
onChanged: (b) => AppConfig.separateChatTypes = b,
storeKey: SettingKeys.separateChatTypes,
defaultValue: AppConfig.separateChatTypes,
),
],
),
),

View File

@ -480,6 +480,9 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
store
.getItemBool(SettingKeys.hideUnknownEvents, AppConfig.hideUnknownEvents)
.then((value) => AppConfig.hideUnknownEvents = value);
store
.getItemBool(SettingKeys.separateChatTypes, AppConfig.separateChatTypes)
.then((value) => AppConfig.separateChatTypes = value);
store
.getItemBool(SettingKeys.autoplayImages, AppConfig.autoplayImages)
.then((value) => AppConfig.autoplayImages = value);