feat: New spaces and chat list design

This commit is contained in:
krille-chan 2024-07-14 16:49:46 +02:00
parent 396a89657d
commit 5c23453e66
No known key found for this signature in database
17 changed files with 1270 additions and 1582 deletions

View file

@ -196,6 +196,19 @@
"supportedVersions": {} "supportedVersions": {}
} }
}, },
"countChatsAndCountParticipants": "{chats} chats and {participants} participants",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"noMoreChatsFound": "No more chats found...",
"joinedChats": "Joined chats",
"unread": "Unread",
"space": "Space",
"spaces": "Spaces",
"banFromChat": "Ban from chat", "banFromChat": "Ban from chat",
"@banFromChat": { "@banFromChat": {
"type": "text", "type": "text",

View file

@ -44,7 +44,6 @@ abstract class AppConfig {
static bool hideRedactedEvents = false; static bool hideRedactedEvents = false;
static bool hideUnknownEvents = true; static bool hideUnknownEvents = true;
static bool hideUnimportantStateEvents = true; static bool hideUnimportantStateEvents = true;
static bool separateChatTypes = false;
static bool autoplayImages = true; static bool autoplayImages = true;
static bool sendTypingNotifications = true; static bool sendTypingNotifications = true;
static bool sendPublicReadReceipts = true; static bool sendPublicReadReceipts = true;

View file

@ -92,12 +92,8 @@ abstract class AppRoutes {
FluffyThemes.isColumnMode(context) && FluffyThemes.isColumnMode(context) &&
state.fullPath?.startsWith('/rooms/settings') == false state.fullPath?.startsWith('/rooms/settings') == false
? TwoColumnLayout( ? TwoColumnLayout(
displayNavigationRail:
state.path?.startsWith('/rooms/settings') != true,
mainView: ChatList( mainView: ChatList(
activeChat: state.pathParameters['roomid'], activeChat: state.pathParameters['roomid'],
displayNavigationRail:
state.path?.startsWith('/rooms/settings') != true,
), ),
sideView: child, sideView: child,
) )
@ -175,7 +171,6 @@ abstract class AppRoutes {
? TwoColumnLayout( ? TwoColumnLayout(
mainView: const Settings(), mainView: const Settings(),
sideView: child, sideView: child,
displayNavigationRail: false,
) )
: child, : child,
), ),

View file

@ -4,7 +4,6 @@ abstract class SettingKeys {
static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents'; static const String hideUnknownEvents = 'chat.fluffy.hideUnknownEvents';
static const String hideUnimportantStateEvents = static const String hideUnimportantStateEvents =
'chat.fluffy.hideUnimportantStateEvents'; 'chat.fluffy.hideUnimportantStateEvents';
static const String separateChatTypes = 'chat.fluffy.separateChatTypes';
static const String sentry = 'sentry'; static const String sentry = 'sentry';
static const String theme = 'theme'; static const String theme = 'theme';
static const String amoledEnabled = 'amoled_enabled'; static const String amoledEnabled = 'amoled_enabled';

View file

@ -10,12 +10,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_shortcuts/flutter_shortcuts.dart'; import 'package:flutter_shortcuts/flutter_shortcuts.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:uni_links/uni_links.dart'; import 'package:uni_links/uni_links.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/send_file_dialog.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
@ -35,7 +36,6 @@ import 'package:fluffychat/utils/tor_stub.dart'
enum SelectMode { enum SelectMode {
normal, normal,
share, share,
select,
} }
enum PopupMenuAction { enum PopupMenuAction {
@ -49,19 +49,32 @@ enum PopupMenuAction {
enum ActiveFilter { enum ActiveFilter {
allChats, allChats,
unread,
groups, groups,
messages,
spaces, spaces,
} }
extension LocalizedActiveFilter on ActiveFilter {
String toLocalizedString(BuildContext context) {
switch (this) {
case ActiveFilter.allChats:
return L10n.of(context)!.all;
case ActiveFilter.unread:
return L10n.of(context)!.unread;
case ActiveFilter.groups:
return L10n.of(context)!.groups;
case ActiveFilter.spaces:
return L10n.of(context)!.spaces;
}
}
}
class ChatList extends StatefulWidget { class ChatList extends StatefulWidget {
static BuildContext? contextForVoip; static BuildContext? contextForVoip;
final bool displayNavigationRail;
final String? activeChat; final String? activeChat;
const ChatList({ const ChatList({
super.key, super.key,
this.displayNavigationRail = false,
required this.activeChat, required this.activeChat,
}); });
@ -77,85 +90,240 @@ class ChatListController extends State<ChatList>
StreamSubscription? _intentUriStreamSubscription; StreamSubscription? _intentUriStreamSubscription;
bool get displayNavigationBar => void createNewSpace() {
!FluffyThemes.isColumnMode(context) && context.push<String?>('/rooms/newspace');
(spaces.isNotEmpty || AppConfig.separateChatTypes);
String? activeSpaceId;
void resetActiveSpaceId() {
setState(() {
selectedRoomIds.clear();
activeSpaceId = null;
});
} }
void setActiveSpace(String? spaceId) { ActiveFilter activeFilter = ActiveFilter.allChats;
setState(() {
selectedRoomIds.clear();
activeSpaceId = spaceId;
activeFilter = ActiveFilter.spaces;
});
}
void createNewSpace() async { String? _activeSpaceId;
final spaceId = await context.push<String?>('/rooms/newspace'); String? get activeSpaceId => _activeSpaceId;
if (spaceId != null) {
setActiveSpace(spaceId); void setActiveSpace(String spaceId) => setState(() {
_activeSpaceId = spaceId;
});
void clearActiveSpace() => setState(() {
_activeSpaceId = null;
});
void addChatAction() async {
if (activeSpaceId == null) {
context.go('/rooms/newprivatechat');
return;
} }
}
int get selectedIndex { final roomType = await showConfirmationDialog(
switch (activeFilter) { context: context,
case ActiveFilter.allChats: title: L10n.of(context)!.addChatOrSubSpace,
case ActiveFilter.messages: actions: [
return 0; AlertDialogAction(
case ActiveFilter.groups: key: AddRoomType.subspace,
return 1; label: L10n.of(context)!.createNewSpace,
case ActiveFilter.spaces: ),
return AppConfig.separateChatTypes ? 2 : 1; AlertDialogAction(
} key: AddRoomType.chat,
} label: L10n.of(context)!.createGroup,
),
],
);
if (roomType == null) return;
ActiveFilter getActiveFilterByDestination(int? i) { final names = await showTextInputDialog(
switch (i) { context: context,
case 1: title: roomType == AddRoomType.subspace
if (AppConfig.separateChatTypes) { ? L10n.of(context)!.createNewSpace
return ActiveFilter.groups; : L10n.of(context)!.createGroup,
textFields: [
DialogTextField(
hintText: roomType == AddRoomType.subspace
? L10n.of(context)!.spaceName
: L10n.of(context)!.groupName,
minLines: 1,
maxLines: 1,
maxLength: 64,
validator: (text) {
if (text == null || text.isEmpty) {
return L10n.of(context)!.pleaseChoose;
}
return null;
},
),
DialogTextField(
hintText: L10n.of(context)!.chatDescription,
minLines: 4,
maxLines: 8,
maxLength: 255,
),
],
okLabel: L10n.of(context)!.create,
cancelLabel: L10n.of(context)!.cancel,
);
if (names == null) return;
final client = Matrix.of(context).client;
final result = await showFutureLoadingDialog(
context: context,
future: () async {
late final String roomId;
final activeSpace = client.getRoomById(activeSpaceId!)!;
await activeSpace.postLoad();
if (roomType == AddRoomType.subspace) {
roomId = await client.createSpace(
name: names.first,
topic: names.last.isEmpty ? null : names.last,
visibility: activeSpace.joinRules == JoinRules.public
? sdk.Visibility.public
: sdk.Visibility.private,
);
} else {
roomId = await client.createGroupChat(
groupName: names.first,
preset: activeSpace.joinRules == JoinRules.public
? CreateRoomPreset.publicChat
: CreateRoomPreset.privateChat,
visibility: activeSpace.joinRules == JoinRules.public
? sdk.Visibility.public
: sdk.Visibility.private,
initialState: names.length > 1 && names.last.isNotEmpty
? [
sdk.StateEvent(
type: sdk.EventTypes.RoomTopic,
content: {'topic': names.last},
),
]
: null,
);
} }
return ActiveFilter.spaces; await activeSpace.setSpaceChild(roomId);
case 2: },
return ActiveFilter.spaces; );
case 0: if (result.error != null) return;
default: }
if (AppConfig.separateChatTypes) {
return ActiveFilter.messages; void onChatTap(Room room, BuildContext context) async {
} if (room.isSpace) {
return ActiveFilter.allChats; setActiveSpace(room.id);
return;
}
if (room.membership == Membership.invite) {
final inviterId =
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
final inviteAction = await showModalActionSheet<InviteActions>(
context: context,
message: room.isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat,
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
actions: [
SheetAction(
key: InviteActions.accept,
label: L10n.of(context)!.accept,
icon: Icons.check_outlined,
isDefaultAction: true,
),
SheetAction(
key: InviteActions.decline,
label: L10n.of(context)!.decline,
icon: Icons.close_outlined,
isDestructiveAction: true,
),
SheetAction(
key: InviteActions.block,
label: L10n.of(context)!.block,
icon: Icons.block_outlined,
isDestructiveAction: true,
),
],
);
if (inviteAction == null) return;
if (inviteAction == InviteActions.block) {
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
return;
}
if (inviteAction == InviteActions.decline) {
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
return;
}
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
);
if (joinResult.error != null) return;
} }
}
void onDestinationSelected(int? i) { if (room.membership == Membership.ban) {
setState(() { ScaffoldMessenger.of(context).showSnackBar(
selectedRoomIds.clear(); SnackBar(
activeFilter = getActiveFilterByDestination(i); content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
}); ),
} );
return;
}
ActiveFilter activeFilter = AppConfig.separateChatTypes if (room.membership == Membership.leave) {
? ActiveFilter.messages context.go('/rooms/archive/${room.id}');
: ActiveFilter.allChats; return;
}
// Share content into this room
final shareContent = Matrix.of(context).shareContent;
if (shareContent != null) {
final shareFile = shareContent.tryGet<MatrixFile>('file');
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
shareFile != null) {
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
files: [shareFile],
room: room,
),
);
Matrix.of(context).shareContent = null;
} else {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context)!.forward,
message: L10n.of(context)!.forwardMessageTo(
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
okLabel: L10n.of(context)!.forward,
cancelLabel: L10n.of(context)!.cancel,
);
if (consent == OkCancelResult.cancel) {
Matrix.of(context).shareContent = null;
return;
}
if (consent == OkCancelResult.ok) {
room.sendEvent(shareContent);
Matrix.of(context).shareContent = null;
}
}
}
context.go('/rooms/${room.id}');
}
bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) { bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) {
switch (activeFilter) { switch (activeFilter) {
case ActiveFilter.allChats: case ActiveFilter.allChats:
return (room) => !room.isSpace; return (room) => true;
case ActiveFilter.groups: case ActiveFilter.groups:
return (room) => !room.isSpace && !room.isDirectChat; return (room) => !room.isSpace && !room.isDirectChat;
case ActiveFilter.messages: case ActiveFilter.unread:
return (room) => !room.isSpace && room.isDirectChat; return (room) => room.isUnreadOrInvited;
case ActiveFilter.spaces: case ActiveFilter.spaces:
return (r) => r.isSpace; return (room) => room.isSpace;
} }
} }
@ -331,15 +499,11 @@ class ChatListController extends State<ChatList>
List<Room> get spaces => List<Room> get spaces =>
Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); Matrix.of(context).client.rooms.where((r) => r.isSpace).toList();
final selectedRoomIds = <String>{};
String? get activeChat => widget.activeChat; String? get activeChat => widget.activeChat;
SelectMode get selectMode => Matrix.of(context).shareContent != null SelectMode get selectMode => Matrix.of(context).shareContent != null
? SelectMode.share ? SelectMode.share
: selectedRoomIds.isEmpty : SelectMode.normal;
? SelectMode.normal
: SelectMode.select;
void _processIncomingSharedFiles(List<SharedMediaFile> files) { void _processIncomingSharedFiles(List<SharedMediaFile> files) {
if (files.isEmpty) return; if (files.isEmpty) return;
@ -448,80 +612,67 @@ class ChatListController extends State<ChatList>
super.dispose(); super.dispose();
} }
void toggleSelection(String roomId) { void chatContextAction(Room room) async {
setState( final action = await showModalActionSheet<ChatContextAction>(
() => selectedRoomIds.contains(roomId) context: context,
? selectedRoomIds.remove(roomId) title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
: selectedRoomIds.add(roomId), actions: [
SheetAction(
key: ChatContextAction.markUnread,
icon: room.markedUnread
? Icons.mark_as_unread
: Icons.mark_as_unread_outlined,
label: room.markedUnread
? L10n.of(context)!.markAsRead
: L10n.of(context)!.unread,
),
SheetAction(
key: ChatContextAction.favorite,
icon: room.isFavourite ? Icons.pin : Icons.pin_outlined,
label: room.isFavourite
? L10n.of(context)!.unpin
: L10n.of(context)!.pin,
),
SheetAction(
key: ChatContextAction.mute,
icon: room.pushRuleState == PushRuleState.notify
? Icons.notifications_off_outlined
: Icons.notifications,
label: room.pushRuleState == PushRuleState.notify
? L10n.of(context)!.muteChat
: L10n.of(context)!.unmuteChat,
),
SheetAction(
isDestructiveAction: true,
key: ChatContextAction.leave,
icon: Icons.delete_outlined,
label: L10n.of(context)!.leave,
),
],
); );
}
Future<void> toggleUnread() async { if (action == null) return;
if (!mounted) return;
await showFutureLoadingDialog( await showFutureLoadingDialog(
context: context, context: context,
future: () async { future: () {
final markUnread = anySelectedRoomNotMarkedUnread; switch (action) {
final client = Matrix.of(context).client; case ChatContextAction.favorite:
for (final roomId in selectedRoomIds) { return room.setFavourite(!room.isFavourite);
final room = client.getRoomById(roomId)!; case ChatContextAction.markUnread:
if (room.markedUnread == markUnread) continue; return room.markUnread(!room.markedUnread);
await client.getRoomById(roomId)!.markUnread(markUnread); case ChatContextAction.mute:
return room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
);
case ChatContextAction.leave:
return room.leave();
} }
}, },
); );
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,
message: L10n.of(context)!.archiveRoomDescription,
) ==
OkCancelResult.ok;
if (!confirmed) return;
await showFutureLoadingDialog(
context: context,
future: () => _archiveSelectedRooms(),
);
setState(() {});
} }
void dismissStatusList() async { void dismissStatusList() async {
@ -568,76 +719,6 @@ class ChatListController extends State<ChatList>
); );
} }
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> addToSpace() async {
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
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
)
.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) {
if (!mounted) return;
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; bool waitForFirstSync = false;
Future<void> _waitForFirstSync() async { Future<void> _waitForFirstSync() async {
@ -666,19 +747,20 @@ class ChatListController extends State<ChatList>
void cancelAction() { void cancelAction() {
if (selectMode == SelectMode.share) { if (selectMode == SelectMode.share) {
setState(() => Matrix.of(context).shareContent = null); setState(() => Matrix.of(context).shareContent = null);
} else {
setState(() => selectedRoomIds.clear());
} }
} }
void setActiveFilter(ActiveFilter filter) {
setState(() {
activeFilter = filter;
});
}
void setActiveClient(Client client) { void setActiveClient(Client client) {
context.go('/rooms'); context.go('/rooms');
setState(() { setState(() {
activeFilter = AppConfig.separateChatTypes activeFilter = ActiveFilter.allChats;
? ActiveFilter.messages _activeSpaceId = null;
: ActiveFilter.allChats;
activeSpaceId = null;
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client); Matrix.of(context).setActiveClient(client);
}); });
_clientStream.add(client); _clientStream.add(client);
@ -687,7 +769,7 @@ class ChatListController extends State<ChatList>
void setActiveBundle(String bundle) { void setActiveBundle(String bundle) {
context.go('/rooms'); context.go('/rooms');
setState(() { setState(() {
selectedRoomIds.clear(); _activeSpaceId = null;
Matrix.of(context).activeBundle = bundle; Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context) if (!Matrix.of(context)
.currentBundle! .currentBundle!
@ -780,3 +862,18 @@ class ChatListController extends State<ChatList>
} }
enum EditBundleAction { addToBundle, removeFromBundle } enum EditBundleAction { addToBundle, removeFromBundle }
enum InviteActions {
accept,
decline,
block,
}
enum AddRoomType { chat, subspace }
enum ChatContextAction {
favorite,
markUnread,
mute,
leave,
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
@ -11,7 +10,6 @@ import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; import 'package:fluffychat/pages/chat_list/status_msg_list.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart';
@ -29,6 +27,17 @@ class ChatListViewBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final activeSpace = controller.activeSpaceId;
if (activeSpace != null) {
return SpaceView(
spaceId: activeSpace,
onBack: controller.clearActiveSpace,
onChatTab: (room) => controller.onChatTap(room, context),
onChatContext: (room) => controller.chatContextAction(room),
activeChat: controller.activeChat,
toParentSpace: controller.setActiveSpace,
);
}
final publicRooms = controller.roomSearchResult?.chunk final publicRooms = controller.roomSearchResult?.chunk
.where((room) => room.roomType != 'm.space') .where((room) => room.roomType != 'm.space')
.toList(); .toList();
@ -43,224 +52,281 @@ class ChatListViewBody extends StatelessWidget {
final subtitleColor = final subtitleColor =
Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50); Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50);
final filter = controller.searchController.text.toLowerCase(); final filter = controller.searchController.text.toLowerCase();
return PageTransitionSwitcher( return StreamBuilder(
transitionBuilder: ( key: ValueKey(
Widget child, client.userID.toString(),
Animation<double> primaryAnimation, ),
Animation<double> secondaryAnimation, stream: client.onSync.stream
) { .where((s) => s.hasRoomUpdate)
return SharedAxisTransition( .rateLimit(const Duration(seconds: 1)),
animation: primaryAnimation, builder: (context, _) {
secondaryAnimation: secondaryAnimation, final rooms = controller.filteredRooms;
transitionType: SharedAxisTransitionType.vertical,
fillColor: Theme.of(context).scaffoldBackgroundColor, final spaces = rooms.where((r) => r.isSpace);
child: child, final spaceDelegateCandidates = <String, Room>{};
); for (final space in spaces) {
}, spaceDelegateCandidates[space.id] = space;
child: StreamBuilder( for (final spaceChild in space.spaceChildren) {
key: ValueKey( final roomId = spaceChild.roomId;
client.userID.toString() + if (roomId == null) continue;
controller.activeFilter.toString() + spaceDelegateCandidates[roomId] = space;
controller.activeSpaceId.toString(),
),
stream: client.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, _) {
if (controller.activeFilter == ActiveFilter.spaces) {
return SpaceView(
controller,
scrollController: controller.scrollController,
key: Key(controller.activeSpaceId ?? 'Spaces'),
);
} }
final rooms = controller.filteredRooms; }
return SafeArea( final spaceDelegates = <String>{};
child: CustomScrollView(
controller: controller.scrollController, return SafeArea(
slivers: [ child: CustomScrollView(
ChatListHeader(controller: controller), controller: controller.scrollController,
SliverList( slivers: [
delegate: SliverChildListDelegate( ChatListHeader(controller: controller),
[ SliverList(
if (controller.isSearchMode) ...[ delegate: SliverChildListDelegate(
SearchTitle( [
title: L10n.of(context)!.publicRooms, if (controller.isSearchMode) ...[
icon: const Icon(Icons.explore_outlined), SearchTitle(
title: L10n.of(context)!.publicRooms,
icon: const Icon(Icons.explore_outlined),
),
PublicRoomsHorizontalList(publicRooms: publicRooms),
SearchTitle(
title: L10n.of(context)!.publicSpaces,
icon: const Icon(Icons.workspaces_outlined),
),
PublicRoomsHorizontalList(publicRooms: publicSpaces),
SearchTitle(
title: L10n.of(context)!.users,
icon: const Icon(Icons.group_outlined),
),
AnimatedContainer(
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
height: userSearchResult == null ||
userSearchResult.results.isEmpty
? 0
: 106,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: userSearchResult == null
? null
: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: userSearchResult.results.length,
itemBuilder: (context, i) => _SearchItem(
title:
userSearchResult.results[i].displayName ??
userSearchResult
.results[i].userId.localpart ??
L10n.of(context)!.unknownDevice,
avatar: userSearchResult.results[i].avatarUrl,
onPressed: () => showAdaptiveBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
profile: userSearchResult.results[i],
outerContext: context,
),
),
),
),
),
],
if (!controller.isSearchMode && AppConfig.showPresences)
GestureDetector(
onLongPress: () => controller.dismissStatusList(),
child: StatusMessageList(
onStatusEdit: controller.setStatus,
), ),
PublicRoomsHorizontalList(publicRooms: publicRooms), ),
SearchTitle( const ConnectionStatusHeader(),
title: L10n.of(context)!.publicSpaces, AnimatedContainer(
icon: const Icon(Icons.workspaces_outlined), height: controller.isTorBrowser ? 64 : 0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.dehydrate,
), ),
PublicRoomsHorizontalList(publicRooms: publicSpaces), ),
SearchTitle( ),
title: L10n.of(context)!.users, if (client.rooms.isNotEmpty && !controller.isSearchMode)
icon: const Icon(Icons.group_outlined), SizedBox(
), height: 44,
AnimatedContainer( child: ListView(
clipBehavior: Clip.hardEdge, padding: const EdgeInsets.symmetric(
decoration: const BoxDecoration(), horizontal: 12.0,
height: userSearchResult == null || vertical: 6,
userSearchResult.results.isEmpty ),
? 0 shrinkWrap: true,
: 106, scrollDirection: Axis.horizontal,
duration: FluffyThemes.animationDuration, children: ActiveFilter.values
curve: FluffyThemes.animationCurve, .map(
child: userSearchResult == null (filter) => Padding(
? null padding:
: ListView.builder( const EdgeInsets.symmetric(horizontal: 4),
scrollDirection: Axis.horizontal, child: InkWell(
itemCount: userSearchResult.results.length, borderRadius: BorderRadius.circular(
itemBuilder: (context, i) => _SearchItem( AppConfig.borderRadius,
title: userSearchResult ),
.results[i].displayName ?? onTap: () =>
userSearchResult controller.setActiveFilter(filter),
.results[i].userId.localpart ?? child: Container(
L10n.of(context)!.unknownDevice, padding: const EdgeInsets.symmetric(
avatar: horizontal: 12,
userSearchResult.results[i].avatarUrl, vertical: 6,
onPressed: () => showAdaptiveBottomSheet( ),
context: context, decoration: BoxDecoration(
builder: (c) => UserBottomSheet( color: filter == controller.activeFilter
profile: userSearchResult.results[i], ? Theme.of(context)
outerContext: context, .colorScheme
.primary
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
alignment: Alignment.center,
child: Text(
filter.toLocalizedString(context),
style: TextStyle(
fontWeight:
filter == controller.activeFilter
? FontWeight.bold
: FontWeight.normal,
color:
filter == controller.activeFilter
? Theme.of(context)
.colorScheme
.onPrimary
: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
), ),
), ),
), ),
), ),
), )
], .toList(),
if (!controller.isSearchMode &&
controller.activeFilter != ActiveFilter.groups &&
AppConfig.showPresences)
GestureDetector(
onLongPress: () => controller.dismissStatusList(),
child: StatusMessageList(
onStatusEdit: controller.setStatus,
),
),
const ConnectionStatusHeader(),
AnimatedContainer(
height: controller.isTorBrowser ? 64 : 0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.dehydrate,
),
), ),
), ),
if (controller.isSearchMode) if (controller.isSearchMode)
SearchTitle( SearchTitle(
title: L10n.of(context)!.chats, title: L10n.of(context)!.chats,
icon: const Icon(Icons.forum_outlined), icon: const Icon(Icons.forum_outlined),
),
if (client.prevBatch != null &&
rooms.isEmpty &&
!controller.isSearchMode) ...[
Padding(
padding: const EdgeInsets.all(32.0),
child: Icon(
CupertinoIcons.chat_bubble_2,
size: 128,
color: Theme.of(context).colorScheme.secondary,
), ),
if (client.prevBatch != null && ),
rooms.isEmpty && ],
!controller.isSearchMode) ...[ ],
Padding( ),
padding: const EdgeInsets.all(32.0), ),
child: Icon( if (client.prevBatch == null)
CupertinoIcons.chat_bubble_2, SliverList(
size: 128, delegate: SliverChildBuilderDelegate(
color: (context, i) => Opacity(
Theme.of(context).colorScheme.onInverseSurface, opacity: (dummyChatCount - i) / dummyChatCount,
child: ListTile(
leading: CircleAvatar(
backgroundColor: titleColor,
child: CircularProgressIndicator(
strokeWidth: 1,
color: Theme.of(context).textTheme.bodyLarge!.color,
), ),
), ),
], title: Row(
], children: [
Expanded(
child: Container(
height: 14,
decoration: BoxDecoration(
color: titleColor,
borderRadius: BorderRadius.circular(3),
),
),
),
const SizedBox(width: 36),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 12),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
],
),
subtitle: Container(
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(3),
),
height: 12,
margin: const EdgeInsets.only(right: 22),
),
),
),
childCount: dummyChatCount,
), ),
), ),
if (client.prevBatch == null) if (client.prevBatch != null)
SliverList( SliverList.builder(
delegate: SliverChildBuilderDelegate( itemCount: rooms.length,
(context, i) => Opacity( itemBuilder: (BuildContext context, int i) {
opacity: (dummyChatCount - i) / dummyChatCount, var room = rooms[i];
child: ListTile( if (controller.activeFilter != ActiveFilter.groups) {
leading: CircleAvatar( final parent = room.isSpace
backgroundColor: titleColor, ? room
child: CircularProgressIndicator( : spaceDelegateCandidates[room.id];
strokeWidth: 1, if (parent != null) {
color: if (spaceDelegates.contains(parent.id)) {
Theme.of(context).textTheme.bodyLarge!.color, return const SizedBox.shrink();
), }
), spaceDelegates.add(parent.id);
title: Row( room = parent;
children: [ }
Expanded( }
child: Container(
height: 14, return ChatListItem(
decoration: BoxDecoration( room,
color: titleColor, lastEventRoom: rooms[i],
borderRadius: BorderRadius.circular(3), key: Key('chat_list_item_${room.id}'),
), filter: filter,
), onTap: () => controller.onChatTap(room, context),
), onLongPress: () => controller.chatContextAction(room),
const SizedBox(width: 36), activeChat: controller.activeChat == room.id,
Container( );
height: 14, },
width: 14, ),
decoration: BoxDecoration( ],
color: subtitleColor, ),
borderRadius: BorderRadius.circular(14), );
), },
),
const SizedBox(width: 12),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
],
),
subtitle: Container(
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(3),
),
height: 12,
margin: const EdgeInsets.only(right: 22),
),
),
),
childCount: dummyChatCount,
),
),
if (client.prevBatch != null)
SliverList.builder(
itemCount: rooms.length,
itemBuilder: (BuildContext context, int i) {
return ChatListItem(
rooms[i],
key: Key('chat_list_item_${rooms[i].id}'),
filter: filter,
selected:
controller.selectedRoomIds.contains(rooms[i].id),
onTap: controller.selectMode == SelectMode.select
? () => controller.toggleSelection(rooms[i].id)
: () => onChatTap(rooms[i], context),
onLongPress: () =>
controller.toggleSelection(rooms[i].id),
activeChat: controller.activeChat == rooms[i].id,
);
},
),
],
),
);
},
),
); );
} }
} }

View file

@ -43,88 +43,77 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
L10n.of(context)!.share, L10n.of(context)!.share,
key: const ValueKey(SelectMode.share), key: const ValueKey(SelectMode.share),
) )
: selectMode == SelectMode.select : TextField(
? Text( controller: controller.searchController,
controller.selectedRoomIds.length.toString(), focusNode: controller.searchFocusNode,
key: const ValueKey(SelectMode.select), textInputAction: TextInputAction.search,
) onChanged: (text) => controller.onSearchEnter(
: TextField( text,
controller: controller.searchController, globalSearch: globalSearch,
focusNode: controller.searchFocusNode, ),
textInputAction: TextInputAction.search, decoration: InputDecoration(
onChanged: (text) => controller.onSearchEnter( fillColor: Theme.of(context).colorScheme.secondaryContainer,
text, border: OutlineInputBorder(
globalSearch: globalSearch, borderSide: BorderSide.none,
), borderRadius: BorderRadius.circular(99),
decoration: InputDecoration(
fillColor: Theme.of(context).colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: TextButton.icon(
onPressed: controller.setServer,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(
controller.searchServer ??
Matrix.of(context)
.client
.homeserver!
.host,
maxLines: 2,
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
), ),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context).colorScheme.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: TextButton.icon(
onPressed: controller.setServer,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(
controller.searchServer ??
Matrix.of(context).client.homeserver!.host,
maxLines: 2,
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
),
actions: selectMode == SelectMode.share actions: selectMode == SelectMode.share
? [ ? [
Padding( Padding(
@ -135,48 +124,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
child: ClientChooserButton(controller), child: ClientChooserButton(controller),
), ),
] ]
: selectMode == SelectMode.select : null,
? [
if (controller.spaces.isNotEmpty)
IconButton(
tooltip: L10n.of(context)!.addToSpace,
icon: const Icon(Icons.workspaces_outlined),
onPressed: controller.addToSpace,
),
IconButton(
tooltip: L10n.of(context)!.toggleUnread,
icon: Icon(
controller.anySelectedRoomNotMarkedUnread
? Icons.mark_chat_unread_outlined
: Icons.mark_chat_read_outlined,
),
onPressed: controller.toggleUnread,
),
IconButton(
tooltip: L10n.of(context)!.toggleFavorite,
icon: Icon(
controller.anySelectedRoomNotFavorite
? Icons.push_pin
: Icons.push_pin_outlined,
),
onPressed: controller.toggleFavouriteRoom,
),
IconButton(
icon: Icon(
controller.anySelectedRoomNotMuted
? Icons.notifications_off_outlined
: Icons.notifications_outlined,
),
tooltip: L10n.of(context)!.toggleMuted,
onPressed: controller.toggleMuted,
),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: L10n.of(context)!.archive,
onPressed: controller.archiveAction,
),
]
: null,
); );
} }

View file

@ -17,8 +17,8 @@ enum ArchivedRoomAction { delete, rejoin }
class ChatListItem extends StatelessWidget { class ChatListItem extends StatelessWidget {
final Room room; final Room room;
final Room? lastEventRoom;
final bool activeChat; final bool activeChat;
final bool selected;
final void Function()? onLongPress; final void Function()? onLongPress;
final void Function()? onForget; final void Function()? onForget;
final void Function() onTap; final void Function() onTap;
@ -27,11 +27,11 @@ class ChatListItem extends StatelessWidget {
const ChatListItem( const ChatListItem(
this.room, { this.room, {
this.activeChat = false, this.activeChat = false,
this.selected = false,
required this.onTap, required this.onTap,
this.onLongPress, this.onLongPress,
this.onForget, this.onForget,
this.filter, this.filter,
this.lastEventRoom,
super.key, super.key,
}); });
@ -64,24 +64,23 @@ class ChatListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMuted = room.pushRuleState != PushRuleState.notify; final isMuted = room.pushRuleState != PushRuleState.notify;
final typingText = room.getLocalizedTypingText(context); final lastEventRoom = this.lastEventRoom ?? room;
final lastEvent = room.lastEvent; final typingText = lastEventRoom.getLocalizedTypingText(context);
final lastEvent = lastEventRoom.lastEvent;
final ownMessage = lastEvent?.senderId == room.client.userID; final ownMessage = lastEvent?.senderId == room.client.userID;
final unread = room.isUnread || room.membership == Membership.invite; final unread =
lastEventRoom.isUnread || lastEventRoom.membership == Membership.invite;
final theme = Theme.of(context); final theme = Theme.of(context);
final directChatMatrixId = room.directChatMatrixID; final directChatMatrixId = room.directChatMatrixID;
final isDirectChat = directChatMatrixId != null; final isDirectChat = directChatMatrixId != null;
final unreadBubbleSize = unread || room.hasNewMessages final unreadBubbleSize = unread || lastEventRoom.hasNewMessages
? room.notificationCount > 0 ? lastEventRoom.notificationCount > 0
? 20.0 ? 20.0
: 14.0 : 14.0
: 0.0; : 0.0;
final hasNotifications = room.notificationCount > 0; final hasNotifications = lastEventRoom.notificationCount > 0;
final backgroundColor = selected final backgroundColor =
? theme.colorScheme.primaryContainer activeChat ? theme.colorScheme.secondaryContainer : null;
: activeChat
? theme.colorScheme.secondaryContainer
: null;
final displayname = room.getLocalizedDisplayname( final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!), MatrixLocals(L10n.of(context)!),
); );
@ -119,6 +118,9 @@ class ChatListItem extends StatelessWidget {
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
scale: hovered ? 1.1 : 1.0, scale: hovered ? 1.1 : 1.0,
child: Avatar( child: Avatar(
borderRadius: room.isSpace
? BorderRadius.circular(AppConfig.borderRadius / 3)
: null,
mxContent: room.avatar, mxContent: room.avatar,
name: displayname, name: displayname,
presenceUserId: directChatMatrixId, presenceUserId: directChatMatrixId,
@ -133,14 +135,12 @@ class ChatListItem extends StatelessWidget {
child: AnimatedScale( child: AnimatedScale(
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
scale: (hovered || selected) ? 1.0 : 0.0, scale: (hovered) ? 1.0 : 0.0,
child: Material( child: Material(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Icon( child: const Icon(
selected Icons.check_circle_outlined,
? Icons.check_circle
: Icons.check_circle_outlined,
size: 18, size: 18,
), ),
), ),
@ -180,7 +180,9 @@ class ChatListItem extends StatelessWidget {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
if (lastEvent != null && room.membership != Membership.invite) if (!room.isSpace &&
lastEvent != null &&
room.membership != Membership.invite)
Padding( Padding(
padding: const EdgeInsets.only(left: 4.0), padding: const EdgeInsets.only(left: 4.0),
child: Text( child: Text(
@ -193,11 +195,30 @@ class ChatListItem extends StatelessWidget {
), ),
), ),
), ),
if (room.isSpace)
const Icon(
Icons.arrow_circle_right_outlined,
size: 18,
),
], ],
), ),
subtitle: Row( subtitle: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
if (room.isSpace) ...[
room.id != lastEventRoom.id &&
lastEventRoom.isUnreadOrInvited
? Avatar(
mxContent: lastEventRoom.avatar,
name: lastEventRoom.name,
size: 18,
)
: const Icon(
Icons.workspaces_outlined,
size: 18,
),
const SizedBox(width: 4),
],
if (typingText.isEmpty && if (typingText.isEmpty &&
ownMessage && ownMessage &&
room.lastEvent!.status.isSending) ...[ room.lastEvent!.status.isSending) ...[
@ -222,62 +243,71 @@ class ChatListItem extends StatelessWidget {
), ),
), ),
Expanded( Expanded(
child: typingText.isNotEmpty child: room.isSpace && !lastEventRoom.isUnreadOrInvited
? Text( ? Text(
typingText, L10n.of(context)!.countChatsAndCountParticipants(
style: TextStyle( room.spaceChildren.length.toString(),
color: theme.colorScheme.primary, (room.summary.mJoinedMemberCount ?? 1).toString(),
), ),
maxLines: 1,
softWrap: false,
) )
: FutureBuilder( : typingText.isNotEmpty
key: ValueKey( ? Text(
'${lastEvent?.eventId}_${lastEvent?.type}', typingText,
), style: TextStyle(
future: needLastEventSender color: theme.colorScheme.primary,
? lastEvent.calcLocalizedBody( ),
MatrixLocals(L10n.of(context)!), maxLines: 1,
hideReply: true, softWrap: false,
hideEdit: true, )
plaintextBody: true, : FutureBuilder(
removeMarkdown: true, key: ValueKey(
withSenderNamePrefix: !isDirectChat || '${lastEvent?.eventId}_${lastEvent?.type}',
directChatMatrixId != ),
room.lastEvent?.senderId, future: needLastEventSender
) ? lastEvent.calcLocalizedBody(
: null, MatrixLocals(L10n.of(context)!),
initialData: lastEvent?.calcLocalizedBodyFallback( hideReply: true,
MatrixLocals(L10n.of(context)!), hideEdit: true,
hideReply: true, plaintextBody: true,
hideEdit: true, removeMarkdown: true,
plaintextBody: true, withSenderNamePrefix: (!isDirectChat ||
removeMarkdown: true, directChatMatrixId !=
withSenderNamePrefix: !isDirectChat || room.lastEvent?.senderId),
directChatMatrixId != )
room.lastEvent?.senderId,
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat
: snapshot.data ??
L10n.of(context)!.emptyChat,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unread || room.hasNewMessages
? FontWeight.bold
: null,
color: theme.colorScheme.onSurfaceVariant,
decoration: room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null, : null,
initialData:
lastEvent?.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix: (!isDirectChat ||
directChatMatrixId !=
room.lastEvent?.senderId),
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat
: snapshot.data ??
L10n.of(context)!.emptyChat,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight:
unread || lastEventRoom.hasNewMessages
? FontWeight.bold
: null,
color: theme.colorScheme.onSurfaceVariant,
decoration: room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null,
),
),
), ),
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AnimatedContainer( AnimatedContainer(
@ -288,7 +318,9 @@ class ChatListItem extends StatelessWidget {
width: !hasNotifications && !unread && !room.hasNewMessages width: !hasNotifications && !unread && !room.hasNewMessages
? 0 ? 0
: (unreadBubbleSize - 9) * : (unreadBubbleSize - 9) *
room.notificationCount.toString().length + lastEventRoom.notificationCount
.toString()
.length +
9, 9,
decoration: BoxDecoration( decoration: BoxDecoration(
color: room.highlightCount > 0 || color: room.highlightCount > 0 ||
@ -303,7 +335,7 @@ class ChatListItem extends StatelessWidget {
child: Center( child: Center(
child: hasNotifications child: hasNotifications
? Text( ? Text(
room.notificationCount.toString(), lastEventRoom.notificationCount.toString(),
style: TextStyle( style: TextStyle(
color: room.highlightCount > 0 color: room.highlightCount > 0
? Colors.white ? Colors.white

View file

@ -1,87 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:badges/badges.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/navi_rail_item.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'chat_list_body.dart'; import 'chat_list_body.dart';
import 'start_chat_fab.dart';
class ChatListView extends StatelessWidget { class ChatListView extends StatelessWidget {
final ChatListController controller; final ChatListController controller;
const ChatListView(this.controller, {super.key}); const ChatListView(this.controller, {super.key});
List<NavigationDestination> getNavigationDestinations(BuildContext context) {
final badgePosition = BadgePosition.topEnd(top: -12, end: -8);
return [
if (AppConfig.separateChatTypes) ...[
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
child: const Icon(Icons.chat_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
child: const Icon(Icons.chat),
),
label: L10n.of(context)!.messages,
),
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
child: const Icon(Icons.group_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
child: const Icon(Icons.group),
),
label: L10n.of(context)!.groups,
),
] else
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
child: const Icon(Icons.chat_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
child: const Icon(Icons.chat),
),
label: L10n.of(context)!.chats,
),
if (controller.spaces.isNotEmpty)
const NavigationDestination(
icon: Icon(Icons.workspaces_outlined),
selectedIcon: Icon(Icons.workspaces),
label: 'Spaces',
),
];
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Matrix.of(context).client;
return StreamBuilder<Object?>( return StreamBuilder<Object?>(
stream: Matrix.of(context).onShareContentChanged.stream, stream: Matrix.of(context).onShareContentChanged.stream,
builder: (_, __) { builder: (_, __) {
@ -89,10 +23,7 @@ class ChatListView extends StatelessWidget {
return PopScope( return PopScope(
canPop: controller.selectMode == SelectMode.normal && canPop: controller.selectMode == SelectMode.normal &&
!controller.isSearchMode && !controller.isSearchMode &&
controller.activeFilter == controller.activeFilter == ActiveFilter.allChats,
(AppConfig.separateChatTypes
? ActiveFilter.messages
: ActiveFilter.allChats),
onPopInvoked: (pop) async { onPopInvoked: (pop) async {
if (pop) return; if (pop) return;
final selMode = controller.selectMode; final selMode = controller.selectMode;
@ -104,122 +35,33 @@ class ChatListView extends StatelessWidget {
controller.cancelAction(); controller.cancelAction();
return; return;
} }
if (controller.activeFilter !=
(AppConfig.separateChatTypes
? ActiveFilter.messages
: ActiveFilter.allChats)) {
controller
.onDestinationSelected(AppConfig.separateChatTypes ? 1 : 0);
return;
}
}, },
child: Row( child: GestureDetector(
children: [ onTap: FocusManager.instance.primaryFocus?.unfocus,
if (FluffyThemes.isColumnMode(context) && excludeFromSemantics: true,
controller.widget.displayNavigationRail) ...[ behavior: HitTestBehavior.translucent,
Builder( child: Scaffold(
builder: (context) { body: ChatListViewBody(controller),
final allSpaces = floatingActionButton: KeyBoardShortcuts(
client.rooms.where((room) => room.isSpace); keysToPress: {
final rootSpaces = allSpaces LogicalKeyboardKey.controlLeft,
.where( LogicalKeyboardKey.keyN,
(space) => !allSpaces.any( },
(parentSpace) => parentSpace.spaceChildren onKeysPressed: () => context.go('/rooms/newprivatechat'),
.any((child) => child.roomId == space.id), helpLabel: L10n.of(context)!.newChat,
), child:
) selectMode == SelectMode.normal && !controller.isSearchMode
.toList(); ? FloatingActionButton.extended(
final destinations = getNavigationDestinations(context); onPressed: controller.addChatAction,
icon: const Icon(Icons.add_outlined),
return SizedBox( label: Text(
width: FluffyThemes.navRailWidth, L10n.of(context)!.chat,
child: ListView.builder( overflow: TextOverflow.fade,
scrollDirection: Axis.vertical,
itemCount: rootSpaces.length + destinations.length,
itemBuilder: (context, i) {
if (i < destinations.length) {
return NaviRailItem(
isSelected: i == controller.selectedIndex,
onTap: () => controller.onDestinationSelected(i),
icon: destinations[i].icon,
selectedIcon: destinations[i].selectedIcon,
toolTip: destinations[i].label,
);
}
i -= destinations.length;
final isSelected =
controller.activeFilter == ActiveFilter.spaces &&
rootSpaces[i].id == controller.activeSpaceId;
return NaviRailItem(
toolTip: rootSpaces[i].getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
), ),
isSelected: isSelected,
onTap: () =>
controller.setActiveSpace(rootSpaces[i].id),
icon: Avatar(
mxContent: rootSpaces[i].avatar,
name: rootSpaces[i].getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
),
size: 32,
),
);
},
),
);
},
),
Container(
color: Theme.of(context).dividerColor,
width: 1,
),
],
Expanded(
child: GestureDetector(
onTap: FocusManager.instance.primaryFocus?.unfocus,
excludeFromSemantics: true,
behavior: HitTestBehavior.translucent,
child: Scaffold(
body: ChatListViewBody(controller),
bottomNavigationBar: controller.displayNavigationBar
? NavigationBar(
elevation: 4,
labelBehavior:
NavigationDestinationLabelBehavior.alwaysShow,
shadowColor:
Theme.of(context).colorScheme.onSurface,
backgroundColor:
Theme.of(context).colorScheme.surface,
surfaceTintColor:
Theme.of(context).colorScheme.surface,
selectedIndex: controller.selectedIndex,
onDestinationSelected:
controller.onDestinationSelected,
destinations: getNavigationDestinations(context),
) )
: null, : const SizedBox.shrink(),
floatingActionButton: KeyBoardShortcuts(
keysToPress: {
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.keyN,
},
onKeysPressed: () => context.go('/rooms/newprivatechat'),
helpLabel: L10n.of(context)!.newChat,
child: selectMode == SelectMode.normal &&
!controller.isSearchMode
? StartChatFloatingActionButton(
activeFilter: controller.activeFilter,
roomsIsEmpty: false,
scrolledToTop: controller.scrolledToTop,
createNewSpace: controller.createNewSpace,
)
: const SizedBox.shrink(),
),
),
),
), ),
], ),
), ),
); );
}, },

View file

@ -5,26 +5,32 @@ import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import '../../utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../../widgets/matrix.dart';
import 'chat_list_header.dart';
class SpaceView extends StatefulWidget { class SpaceView extends StatefulWidget {
final ChatListController controller; final String spaceId;
final ScrollController scrollController; final void Function() onBack;
const SpaceView( final void Function(String spaceId) toParentSpace;
this.controller, { final void Function(Room room) onChatTab;
final void Function(Room room) onChatContext;
final String? activeChat;
const SpaceView({
required this.spaceId,
required this.onBack,
required this.onChatTab,
required this.activeChat,
required this.toParentSpace,
required this.onChatContext,
super.key, super.key,
required this.scrollController,
}); });
@override @override
@ -32,543 +38,449 @@ class SpaceView extends StatefulWidget {
} }
class _SpaceViewState extends State<SpaceView> { class _SpaceViewState extends State<SpaceView> {
static final Map<String, GetSpaceHierarchyResponse> _lastResponse = {}; final List<SpaceRoomsChunk> _discoveredChildren = [];
final TextEditingController _filterController = TextEditingController();
String? prevBatch; String? _nextBatch;
Object? error; bool _noMoreRooms = false;
bool loading = false; bool _isLoading = false;
@override @override
void initState() { void initState() {
loadHierarchy(); _loadHierarchy();
super.initState(); super.initState();
} }
void _refresh() { void _loadHierarchy() async {
_lastResponse.remove(widget.controller.activeSpaceId); final room = Matrix.of(context).client.getRoomById(widget.spaceId);
loadHierarchy(); if (room == null) return;
}
Future<GetSpaceHierarchyResponse?> loadHierarchy([String? prevBatch]) async {
final activeSpaceId = widget.controller.activeSpaceId;
if (activeSpaceId == null) return null;
final client = Matrix.of(context).client;
final activeSpace = client.getRoomById(activeSpaceId);
await activeSpace?.postLoad();
setState(() { setState(() {
error = null; _isLoading = true;
loading = true;
}); });
try { try {
final response = await client.getSpaceHierarchy( final hierarchy = await room.client.getSpaceHierarchy(
activeSpaceId, widget.spaceId,
maxDepth: 1, suggestedOnly: false,
from: prevBatch, maxDepth: 2,
from: _nextBatch,
); );
if (!mounted) return;
if (prevBatch != null) {
response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []);
}
setState(() { setState(() {
_lastResponse[activeSpaceId] = response; _nextBatch = hierarchy.nextBatch;
}); if (hierarchy.nextBatch == null) {
return _lastResponse[activeSpaceId]!; _noMoreRooms = true;
} catch (e) { }
setState(() { _discoveredChildren.addAll(
error = e; hierarchy.rooms
}); .where((c) => room.client.getRoomById(c.roomId) == null),
rethrow;
} finally {
setState(() {
loading = false;
});
}
}
void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async {
final client = Matrix.of(context).client;
final space = client.getRoomById(widget.controller.activeSpaceId!);
if (client.getRoomById(spaceChild.roomId) == null) {
final result = await showFutureLoadingDialog(
context: context,
future: () async {
await client.joinRoom(
spaceChild.roomId,
serverName: space?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == spaceChild.roomId,
)
?.via,
);
if (client.getRoomById(spaceChild.roomId) == null) {
// Wait for room actually appears in sync
await client.waitForRoomInSync(spaceChild.roomId, join: true);
}
},
);
if (result.error != null) return;
_refresh();
}
if (spaceChild.roomType == 'm.space') {
if (spaceChild.roomId == widget.controller.activeSpaceId) {
context.go('/rooms/${spaceChild.roomId}');
} else {
widget.controller.setActiveSpace(spaceChild.roomId);
}
return;
}
context.go('/rooms/${spaceChild.roomId}');
}
void _onSpaceChildContextMenu([
SpaceRoomsChunk? spaceChild,
Room? room,
]) async {
final client = Matrix.of(context).client;
final activeSpaceId = widget.controller.activeSpaceId;
final activeSpace =
activeSpaceId == null ? null : client.getRoomById(activeSpaceId);
final action = await showModalActionSheet<SpaceChildContextAction>(
context: context,
title: spaceChild?.name ??
room?.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
),
message: spaceChild?.topic ?? room?.topic,
actions: [
if (room == null)
SheetAction(
key: SpaceChildContextAction.join,
label: L10n.of(context)!.joinRoom,
icon: Icons.send_outlined,
),
if (spaceChild != null &&
(activeSpace?.canChangeStateEvent(EventTypes.SpaceChild) ?? false))
SheetAction(
key: SpaceChildContextAction.removeFromSpace,
label: L10n.of(context)!.removeFromSpace,
icon: Icons.delete_sweep_outlined,
),
if (room != null)
SheetAction(
key: SpaceChildContextAction.leave,
label: L10n.of(context)!.leave,
icon: Icons.delete_outlined,
isDestructiveAction: true,
),
],
);
if (action == null) return;
switch (action) {
case SpaceChildContextAction.join:
_onJoinSpaceChild(spaceChild!);
break;
case SpaceChildContextAction.leave:
await showFutureLoadingDialog(
context: context,
future: room!.leave,
); );
break; _isLoading = false;
case SpaceChildContextAction.removeFromSpace: });
await showFutureLoadingDialog( } catch (e, s) {
context: context, Logs().w('Unable to load hierarchy', e, s);
future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId), if (!mounted) return;
); ScaffoldMessenger.of(context)
break; .showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
setState(() {
_isLoading = false;
});
} }
} }
void _addChatOrSubSpace() async { void _joinChildRoom(SpaceRoomsChunk item) async {
final roomType = await showConfirmationDialog( final client = Matrix.of(context).client;
context: context, final space = client.getRoomById(widget.spaceId);
title: L10n.of(context)!.addChatOrSubSpace,
actions: [
AlertDialogAction(
key: AddRoomType.subspace,
label: L10n.of(context)!.createNewSpace,
),
AlertDialogAction(
key: AddRoomType.chat,
label: L10n.of(context)!.createGroup,
),
],
);
if (roomType == null) return;
final names = await showTextInputDialog( final consent = await showOkCancelAlertDialog(
context: context, context: context,
title: roomType == AddRoomType.subspace title: item.name ?? item.canonicalAlias ?? L10n.of(context)!.emptyChat,
? L10n.of(context)!.createNewSpace message: item.topic,
: L10n.of(context)!.createGroup, okLabel: L10n.of(context)!.joinRoom,
textFields: [
DialogTextField(
hintText: roomType == AddRoomType.subspace
? L10n.of(context)!.spaceName
: L10n.of(context)!.groupName,
minLines: 1,
maxLines: 1,
maxLength: 64,
validator: (text) {
if (text == null || text.isEmpty) {
return L10n.of(context)!.pleaseChoose;
}
return null;
},
),
DialogTextField(
hintText: L10n.of(context)!.chatDescription,
minLines: 4,
maxLines: 8,
maxLength: 255,
),
],
okLabel: L10n.of(context)!.create,
cancelLabel: L10n.of(context)!.cancel, cancelLabel: L10n.of(context)!.cancel,
); );
if (names == null) return; if (consent != OkCancelResult.ok) return;
final client = Matrix.of(context).client; if (!mounted) return;
final result = await showFutureLoadingDialog(
await showFutureLoadingDialog(
context: context, context: context,
future: () async { future: () async {
late final String roomId; await client.joinRoom(
final activeSpace = client.getRoomById( item.roomId,
widget.controller.activeSpaceId!, serverName: space?.spaceChildren
)!; .firstWhereOrNull(
(child) => child.roomId == item.roomId,
if (roomType == AddRoomType.subspace) { )
roomId = await client.createSpace( ?.via,
name: names.first, );
topic: names.last.isEmpty ? null : names.last, if (client.getRoomById(item.roomId) == null) {
visibility: activeSpace.joinRules == JoinRules.public // Wait for room actually appears in sync
? sdk.Visibility.public await client.waitForRoomInSync(item.roomId, join: true);
: sdk.Visibility.private,
);
} else {
roomId = await client.createGroupChat(
groupName: names.first,
initialState: names.length > 1 && names.last.isNotEmpty
? [
sdk.StateEvent(
type: sdk.EventTypes.RoomTopic,
content: {'topic': names.last},
),
]
: null,
);
} }
await activeSpace.setSpaceChild(roomId);
}, },
); );
if (result.error != null) return; if (!mounted) return;
_refresh();
setState(() {
_discoveredChildren.remove(item);
});
}
void _onSpaceAction(SpaceActions action) async {
final space = Matrix.of(context).client.getRoomById(widget.spaceId);
switch (action) {
case SpaceActions.settings:
await space?.postLoad();
context.push('/rooms/${widget.spaceId}/details');
break;
case SpaceActions.invite:
await space?.postLoad();
context.push('/rooms/${widget.spaceId}/invite');
break;
case SpaceActions.leave:
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context)!.areYouSure,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
message: L10n.of(context)!.archiveRoomDescription,
);
if (!mounted) return;
if (confirmed != OkCancelResult.ok) return;
final success = await showFutureLoadingDialog(
context: context,
future: () async => await space?.leave(),
);
if (!mounted) return;
if (success.error != null) return;
widget.onBack();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Matrix.of(context).client; final room = Matrix.of(context).client.getRoomById(widget.spaceId);
final activeSpaceId = widget.controller.activeSpaceId; final displayname =
final activeSpace = activeSpaceId == null room?.getLocalizedDisplayname() ?? L10n.of(context)!.nothingFound;
? null return Scaffold(
: client.getRoomById( appBar: AppBar(
activeSpaceId, leading: Center(
); child: CloseButton(
final allSpaces = client.rooms.where((room) => room.isSpace); onPressed: widget.onBack,
if (activeSpaceId == null) { ),
final rootSpaces = allSpaces
.where(
(space) =>
!allSpaces.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
) &&
space
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!))
.toLowerCase()
.contains(
widget.controller.searchController.text.toLowerCase(),
),
)
.toList();
return SafeArea(
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
ChatListHeader(controller: widget.controller),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
final rootSpace = rootSpaces[i];
final displayname = rootSpace.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
);
return Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Avatar(
mxContent: rootSpace.avatar,
name: displayname,
),
title: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
L10n.of(context)!.numChats(
rootSpace.spaceChildren.length.toString(),
),
),
onTap: () =>
widget.controller.setActiveSpace(rootSpace.id),
onLongPress: () =>
_onSpaceChildContextMenu(null, rootSpace),
trailing: const Icon(Icons.chevron_right_outlined),
),
);
},
childCount: rootSpaces.length,
),
),
],
), ),
); titleSpacing: 0,
} title: ListTile(
contentPadding: EdgeInsets.zero,
final parentSpace = allSpaces.firstWhereOrNull( leading: Avatar(
(space) => mxContent: room?.avatar,
space.spaceChildren.any((child) => child.roomId == activeSpaceId), name: displayname,
); borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
return PopScope( ),
canPop: parentSpace == null, title: Text(
onPopInvoked: (pop) async { displayname,
if (pop) return; maxLines: 1,
if (parentSpace != null) { overflow: TextOverflow.ellipsis,
widget.controller.setActiveSpace(parentSpace.id); ),
} subtitle: room == null
}, ? null
child: SafeArea( : Text(
child: CustomScrollView( L10n.of(context)!.countChatsAndCountParticipants(
controller: widget.scrollController, room.spaceChildren.length,
slivers: [ room.summary.mJoinedMemberCount ?? 1,
ChatListHeader(controller: widget.controller, globalSearch: false), ),
SliverAppBar( maxLines: 1,
automaticallyImplyLeading: false, overflow: TextOverflow.ellipsis,
primary: false,
titleSpacing: 0,
title: ListTile(
leading: BackButton(
onPressed: () =>
widget.controller.setActiveSpace(parentSpace?.id),
), ),
title: Text( ),
parentSpace == null actions: [
? L10n.of(context)!.allSpaces PopupMenuButton<SpaceActions>(
: parentSpace.getLocalizedDisplayname( onSelected: _onSpaceAction,
MatrixLocals(L10n.of(context)!), itemBuilder: (context) => [
), PopupMenuItem(
), value: SpaceActions.settings,
trailing: IconButton( child: Row(
icon: loading mainAxisSize: MainAxisSize.min,
? const CircularProgressIndicator.adaptive(strokeWidth: 2) children: [
: const Icon(Icons.refresh_outlined), const Icon(Icons.settings_outlined),
onPressed: loading ? null : _refresh, const SizedBox(width: 12),
Text(L10n.of(context)!.settings),
],
), ),
), ),
), PopupMenuItem(
Builder( value: SpaceActions.invite,
builder: (context) { child: Row(
final response = _lastResponse[activeSpaceId]; mainAxisSize: MainAxisSize.min,
final error = this.error; children: [
if (error != null) { const Icon(Icons.person_add_outlined),
return SliverFillRemaining( const SizedBox(width: 12),
child: Column( Text(L10n.of(context)!.invite),
crossAxisAlignment: CrossAxisAlignment.center, ],
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ ),
Padding( PopupMenuItem(
padding: const EdgeInsets.all(16.0), value: SpaceActions.leave,
child: Text(error.toLocalizedString(context)), child: Row(
), mainAxisSize: MainAxisSize.min,
IconButton( children: [
onPressed: _refresh, const Icon(Icons.delete_outlined),
icon: const Icon(Icons.refresh_outlined), const SizedBox(width: 12),
), Text(L10n.of(context)!.leave),
], ],
), ),
); ),
} ],
if (response == null) { ),
return SliverFillRemaining( ],
child: Center( ),
child: Text(L10n.of(context)!.loadingPleaseWait), body: room == null
), ? const Center(
); child: Icon(
} Icons.search_outlined,
final spaceChildren = response.rooms; size: 80,
final canLoadMore = response.nextBatch != null; ),
return SliverList( )
delegate: SliverChildBuilderDelegate( : StreamBuilder(
(context, i) { stream: room.client.onSync.stream
if (canLoadMore && i == spaceChildren.length) { .where((s) => s.hasRoomUpdate)
return Padding( .rateLimit(const Duration(seconds: 1)),
padding: const EdgeInsets.all(16.0), builder: (context, snapshot) {
child: OutlinedButton.icon( final joinedRooms = room.spaceChildren
label: loading .map((child) {
? const LinearProgressIndicator() final roomId = child.roomId;
: Text(L10n.of(context)!.loadMore), if (roomId == null) return null;
icon: const Icon(Icons.chevron_right_outlined), return room.client.getRoomById(roomId);
onPressed: loading })
? null .whereType<Room>()
: () { .where((room) => room.membership != Membership.leave)
loadHierarchy(response.nextBatch); .toList();
},
// Sort rooms by last activity
joinedRooms.sort(
(b, a) => (a.lastEvent?.originServerTs ??
DateTime.fromMillisecondsSinceEpoch(0))
.compareTo(
b.lastEvent?.originServerTs ??
DateTime.fromMillisecondsSinceEpoch(0),
),
);
final joinedParents = room.spaceParents
.map((parent) {
final roomId = parent.roomId;
if (roomId == null) return null;
return room.client.getRoomById(roomId);
})
.whereType<Room>()
.toList();
final filter = _filterController.text.trim().toLowerCase();
return CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
toolbarHeight: 72,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
automaticallyImplyLeading: false,
title: TextField(
controller: _filterController,
onChanged: (_) => setState(() {}),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
fillColor:
Theme.of(context).colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
), ),
); contentPadding: EdgeInsets.zero,
} hintText: L10n.of(context)!.search,
final spaceChild = spaceChildren[i]; hintStyle: TextStyle(
final room = client.getRoomById(spaceChild.roomId); color: Theme.of(context)
if (room != null && !room.isSpace) { .colorScheme
return ChatListItem( .onPrimaryContainer,
room, fontWeight: FontWeight.normal,
onLongPress: () => ),
_onSpaceChildContextMenu(spaceChild, room), floatingLabelBehavior: FloatingLabelBehavior.never,
activeChat: widget.controller.activeChat == room.id, prefixIcon: IconButton(
onTap: () => onChatTap(room, context), onPressed: () {},
); icon: Icon(
} Icons.search_outlined,
final isSpace = spaceChild.roomType == 'm.space';
final topic = spaceChild.topic?.isEmpty ?? true
? null
: spaceChild.topic;
if (spaceChild.roomId == activeSpaceId) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SearchTitle(
title: spaceChild.name ??
spaceChild.canonicalAlias ??
'Space',
icon: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
),
child: Avatar(
size: 24,
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
),
),
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.secondaryContainer .onPrimaryContainer,
.withAlpha(128),
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.edit_outlined),
),
onTap: () => _onJoinSpaceChild(spaceChild),
),
if (activeSpace?.canChangeStateEvent(
EventTypes.SpaceChild,
) ==
true)
Material(
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.group_add_outlined),
),
title:
Text(L10n.of(context)!.addChatOrSubSpace),
trailing:
const Icon(Icons.chevron_right_outlined),
onTap: _addChatOrSubSpace,
),
),
],
);
}
final name = spaceChild.name ??
spaceChild.canonicalAlias ??
L10n.of(context)!.chat;
if (widget.controller.isSearchMode &&
!name.toLowerCase().contains(
widget.controller.searchController.text
.toLowerCase(),
)) {
return const SizedBox.shrink();
}
return Material(
child: ListTile(
leading: Avatar(
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
),
title: Row(
children: [
Expanded(
child: Text(
name,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
if (!isSpace) ...[
const Icon(
Icons.people_outline,
size: 16,
),
const SizedBox(width: 4),
Text(
spaceChild.numJoinedMembers.toString(),
style: const TextStyle(fontSize: 14),
),
],
],
),
onTap: () => room?.isSpace == true
? widget.controller.setActiveSpace(room!.id)
: _onSpaceChildContextMenu(spaceChild, room),
onLongPress: () =>
_onSpaceChildContextMenu(spaceChild, room),
subtitle: Text(
topic ??
(isSpace
? L10n.of(context)!.enterSpace
: L10n.of(context)!.enterRoom),
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
trailing: isSpace
? const Icon(Icons.chevron_right_outlined)
: null,
), ),
); ),
}, ),
childCount: spaceChildren.length + (canLoadMore ? 1 : 0), SliverList.builder(
), itemCount: joinedParents.length,
itemBuilder: (context, i) {
final displayname =
joinedParents[i].getLocalizedDisplayname();
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
minVerticalPadding: 0,
leading: Icon(
Icons.adaptive.arrow_back_outlined,
size: 16,
),
title: Row(
children: [
Avatar(
mxContent: joinedParents[i].avatar,
name: displayname,
size: Avatar.defaultSize / 2,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 4,
),
),
const SizedBox(width: 8),
Expanded(child: Text(displayname)),
],
),
onTap: () =>
widget.toParentSpace(joinedParents[i].id),
),
),
);
},
),
SliverList.builder(
itemCount: joinedRooms.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return SearchTitle(
title: L10n.of(context)!.joinedChats,
icon: const Icon(Icons.chat_outlined),
);
}
i--;
final room = joinedRooms[i];
return ChatListItem(
room,
filter: filter,
onTap: () => widget.onChatTab(room),
onLongPress: () => widget.onChatContext(room),
activeChat: widget.activeChat == room.id,
);
},
),
SliverList.builder(
itemCount: _discoveredChildren.length + 2,
itemBuilder: (context, i) {
if (i == 0) {
return SearchTitle(
title: L10n.of(context)!.discover,
icon: const Icon(Icons.explore_outlined),
);
}
i--;
if (i == _discoveredChildren.length) {
if (_noMoreRooms) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Center(
child: Text(
L10n.of(context)!.noMoreChatsFound,
style: const TextStyle(fontSize: 13),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 2.0,
),
child: TextButton(
onPressed: _isLoading ? null : _loadHierarchy,
child: _isLoading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: Text(L10n.of(context)!.loadMore),
),
);
}
final item = _discoveredChildren[i];
final displayname = item.name ??
item.canonicalAlias ??
L10n.of(context)!.emptyChat;
if (!displayname.toLowerCase().contains(filter)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
onTap: () => _joinChildRoom(item),
leading: Avatar(
mxContent: item.avatarUrl,
name: displayname,
borderRadius: item.roomType == 'm.space'
? BorderRadius.circular(
AppConfig.borderRadius / 2,
)
: null,
),
title: Row(
children: [
Expanded(
child: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(Icons.add_circle_outline_outlined),
],
),
subtitle: Text(
item.topic ??
L10n.of(context)!.countParticipants(
item.numJoinedMembers,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
},
),
],
); );
}, },
), ),
],
),
),
); );
} }
} }
enum SpaceChildContextAction { enum SpaceActions {
join, settings,
invite,
leave, leave,
removeFromSpace,
} }
enum AddRoomType { chat, subspace }

View file

@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import '../../config/themes.dart';
import 'chat_list.dart';
class StartChatFloatingActionButton extends StatelessWidget {
final ActiveFilter activeFilter;
final ValueNotifier<bool> scrolledToTop;
final bool roomsIsEmpty;
final void Function() createNewSpace;
const StartChatFloatingActionButton({
super.key,
required this.activeFilter,
required this.scrolledToTop,
required this.roomsIsEmpty,
required this.createNewSpace,
});
void _onPressed(BuildContext context) async {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
context.go('/rooms/newprivatechat');
break;
case ActiveFilter.groups:
context.go('/rooms/newgroup');
break;
case ActiveFilter.spaces:
createNewSpace();
break;
}
}
IconData get icon {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return Icons.add_outlined;
case ActiveFilter.groups:
return Icons.group_add_outlined;
case ActiveFilter.spaces:
return Icons.workspaces_outlined;
}
}
String getLabel(BuildContext context) {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return roomsIsEmpty
? L10n.of(context)!.startFirstChat
: L10n.of(context)!.newChat;
case ActiveFilter.groups:
return L10n.of(context)!.newGroup;
case ActiveFilter.spaces:
return L10n.of(context)!.newSpace;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: scrolledToTop,
builder: (context, scrolledToTop, _) => AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.none,
child: scrolledToTop
? FloatingActionButton.extended(
onPressed: () => _onPressed(context),
icon: Icon(icon),
label: Text(
getLabel(context),
overflow: TextOverflow.fade,
),
)
: FloatingActionButton(
onPressed: () => _onPressed(context),
child: Icon(icon),
),
),
);
}
}

View file

@ -1,127 +0,0 @@
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:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/send_file_dialog.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/matrix.dart';
void onChatTap(Room room, BuildContext context) async {
if (room.membership == Membership.invite) {
final inviterId =
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
final inviteAction = await showModalActionSheet<InviteActions>(
context: context,
message: room.isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat,
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
actions: [
SheetAction(
key: InviteActions.accept,
label: L10n.of(context)!.accept,
icon: Icons.check_outlined,
isDefaultAction: true,
),
SheetAction(
key: InviteActions.decline,
label: L10n.of(context)!.decline,
icon: Icons.close_outlined,
isDestructiveAction: true,
),
SheetAction(
key: InviteActions.block,
label: L10n.of(context)!.block,
icon: Icons.block_outlined,
isDestructiveAction: true,
),
],
);
if (inviteAction == null) return;
if (inviteAction == InviteActions.block) {
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
return;
}
if (inviteAction == InviteActions.decline) {
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
return;
}
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
);
if (joinResult.error != null) return;
}
if (room.membership == Membership.ban) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
),
);
return;
}
if (room.membership == Membership.leave) {
context.go('/rooms/archive/${room.id}');
return;
}
// Share content into this room
final shareContent = Matrix.of(context).shareContent;
if (shareContent != null) {
final shareFile = shareContent.tryGet<MatrixFile>('file');
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
shareFile != null) {
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
files: [shareFile],
room: room,
),
);
Matrix.of(context).shareContent = null;
} else {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context)!.forward,
message: L10n.of(context)!.forwardMessageTo(
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
okLabel: L10n.of(context)!.forward,
cancelLabel: L10n.of(context)!.cancel,
);
if (consent == OkCancelResult.cancel) {
Matrix.of(context).shareContent = null;
return;
}
if (consent == OkCancelResult.ok) {
room.sendEvent(shareContent);
Matrix.of(context).shareContent = null;
}
}
}
context.go('/rooms/${room.id}');
}
enum InviteActions {
accept,
decline,
block,
}

View file

@ -185,12 +185,6 @@ class SettingsStyleView extends StatelessWidget {
storeKey: SettingKeys.showPresences, storeKey: SettingKeys.showPresences,
defaultValue: AppConfig.showPresences, defaultValue: AppConfig.showPresences,
), ),
SettingsSwitchListTile.adaptive(
title: L10n.of(context)!.separateChatTypes,
onChanged: (b) => AppConfig.separateChatTypes = b,
storeKey: SettingKeys.separateChatTypes,
defaultValue: AppConfig.separateChatTypes,
),
Divider( Divider(
height: 1, height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,

View file

@ -15,6 +15,8 @@ class Avatar extends StatelessWidget {
final Client? client; final Client? client;
final String? presenceUserId; final String? presenceUserId;
final Color? presenceBackgroundColor; final Color? presenceBackgroundColor;
final BorderRadius? borderRadius;
final IconData? icon;
const Avatar({ const Avatar({
this.mxContent, this.mxContent,
@ -24,6 +26,8 @@ class Avatar extends StatelessWidget {
this.client, this.client,
this.presenceUserId, this.presenceUserId,
this.presenceBackgroundColor, this.presenceBackgroundColor,
this.borderRadius,
this.icon,
super.key, super.key,
}); });
@ -50,18 +54,25 @@ class Avatar extends StatelessWidget {
), ),
), ),
); );
final borderRadius = BorderRadius.circular(size / 2); final borderRadius = this.borderRadius ?? BorderRadius.circular(size / 2);
final presenceUserId = this.presenceUserId; final presenceUserId = this.presenceUserId;
final color = final color =
noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor; noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor;
final container = Stack( final container = Stack(
children: [ children: [
ClipRRect( SizedBox(
borderRadius: borderRadius, width: size,
child: Container( height: size,
width: size, child: Material(
height: size,
color: color, color: color,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
side: BorderSide(
width: 0,
color: Theme.of(context).dividerColor,
),
),
clipBehavior: Clip.hardEdge,
child: noPic child: noPic
? textWidget ? textWidget
: MxcImage( : MxcImage(
@ -75,48 +86,49 @@ class Avatar extends StatelessWidget {
), ),
), ),
), ),
PresenceBuilder( if (presenceUserId != null)
client: client, PresenceBuilder(
userId: presenceUserId, client: client,
builder: (context, presence) { userId: presenceUserId,
if (presence == null || builder: (context, presence) {
(presence.presence == PresenceType.offline && if (presence == null ||
presence.lastActiveTimestamp == null)) { (presence.presence == PresenceType.offline &&
return const SizedBox.shrink(); presence.lastActiveTimestamp == null)) {
} return const SizedBox.shrink();
final dotColor = presence.presence.isOnline }
? Colors.green final dotColor = presence.presence.isOnline
: presence.presence.isUnavailable ? Colors.green
? Colors.orange : presence.presence.isUnavailable
: Colors.grey; ? Colors.orange
return Positioned( : Colors.grey;
bottom: -3, return Positioned(
right: -3, bottom: -3,
child: Container( right: -3,
width: 16,
height: 16,
decoration: BoxDecoration(
color: presenceBackgroundColor ??
Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(32),
),
alignment: Alignment.center,
child: Container( child: Container(
width: 10, width: 16,
height: 10, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: dotColor, color: presenceBackgroundColor ??
borderRadius: BorderRadius.circular(16), Theme.of(context).colorScheme.surface,
border: Border.all( borderRadius: BorderRadius.circular(32),
width: 1, ),
color: Theme.of(context).colorScheme.surface, alignment: Alignment.center,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: dotColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
width: 1,
color: Theme.of(context).colorScheme.surface,
),
), ),
), ),
), ),
), );
); },
}, ),
),
], ],
); );
if (onTap == null) return container; if (onTap == null) return container;

View file

@ -3,13 +3,11 @@ import 'package:flutter/material.dart';
class TwoColumnLayout extends StatelessWidget { class TwoColumnLayout extends StatelessWidget {
final Widget mainView; final Widget mainView;
final Widget sideView; final Widget sideView;
final bool displayNavigationRail;
const TwoColumnLayout({ const TwoColumnLayout({
super.key, super.key,
required this.mainView, required this.mainView,
required this.sideView, required this.sideView,
required this.displayNavigationRail,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -20,7 +18,7 @@ class TwoColumnLayout extends StatelessWidget {
Container( Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
width: 360.0 + (displayNavigationRail ? 64 : 0), width: 384.0,
child: mainView, child: mainView,
), ),
Container( Container(

View file

@ -433,10 +433,6 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
store.getBool(SettingKeys.hideUnimportantStateEvents) ?? store.getBool(SettingKeys.hideUnimportantStateEvents) ??
AppConfig.hideUnimportantStateEvents; AppConfig.hideUnimportantStateEvents;
AppConfig.separateChatTypes =
store.getBool(SettingKeys.separateChatTypes) ??
AppConfig.separateChatTypes;
AppConfig.autoplayImages = AppConfig.autoplayImages =
store.getBool(SettingKeys.autoplayImages) ?? AppConfig.autoplayImages; store.getBool(SettingKeys.autoplayImages) ?? AppConfig.autoplayImages;

View file

@ -60,7 +60,7 @@ static void my_application_activate(GApplication* application) {
gtk_window_set_title(window, "FluffyChat"); gtk_window_set_title(window, "FluffyChat");
} }
gtk_window_set_default_size(window, 864, 680); gtk_window_set_default_size(window, 800, 600);
gtk_widget_show(GTK_WIDGET(window)); gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new(); g_autoptr(FlDartProject) project = fl_dart_project_new();