mirror of
https://github.com/krille-chan/fluffychat
synced 2024-09-10 11:45:10 +00:00
feat: Groups and Direct Chats virtual spaces option
This commit is contained in:
parent
36a962b31b
commit
919b0822e5
9 changed files with 285 additions and 87 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
189
lib/pages/chat_list/spaces_entry.dart
Normal file
189
lib/pages/chat_list/spaces_entry.dart
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue