reorganize

This commit is contained in:
Henry Hiles 2025-11-19 13:42:20 -05:00
commit 220c13a245
No known key found for this signature in database
12 changed files with 39 additions and 29 deletions

View file

@ -0,0 +1,64 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
class ChatBox extends StatelessWidget {
final Message? replyToMessage;
final VoidCallback onDismiss;
final Map<String, String> headers;
const ChatBox({
required this.replyToMessage,
required this.onDismiss,
required this.headers,
super.key,
});
@override
Widget build(BuildContext context) => Composer(
sigmaX: 0,
sigmaY: 0,
sendIconColor: Theme.of(context).colorScheme.primary,
sendOnEnter: true,
topWidget: replyToMessage == null
? null
: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
spacing: 8,
children: [
Avatar(
userId: replyToMessage!.authorId,
headers: headers,
size: 16,
),
Text(
replyToMessage!.metadata?["displayName"] ??
replyToMessage!.authorId,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: (replyToMessage is TextMessage)
? Text(
(replyToMessage as TextMessage).text,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
)
: SizedBox(),
),
IconButton(
onPressed: onDismiss,
icon: Icon(Icons.close),
iconSize: 20,
),
],
),
),
),
autofocus: true,
);
}

View file

@ -0,0 +1,53 @@
import "dart:math";
import "package:flutter/material.dart";
class CodeBlock extends StatelessWidget {
final String code;
final String lang;
const CodeBlock(this.code, {required this.lang, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(16)),
child: ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: IntrinsicWidth(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
lang.substring(0, min(lang.length, 15)),
style: TextStyle(fontFamily: "monospace"),
),
),
TextButton(
onPressed: () {},
child: Row(
spacing: 4,
children: [Icon(Icons.copy), Text("Copy")],
),
),
],
),
ColoredBox(
color: theme.colorScheme.surfaceContainerHigh,
child: Container(
constraints: BoxConstraints(minWidth: 250),
padding: EdgeInsets.all(8),
child: SelectableText(code),
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,63 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget {
final Room room;
const MemberList(this.room, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Drawer(
shape: Border(),
child: ref
.watch(MembersController.provider(room))
.betterWhen(
data: (members) => ListView(
children: [
AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.people),
title: Text("Members"),
actionsPadding: EdgeInsets.only(right: 4),
actions: [
if (Scaffold.of(context).hasEndDrawer)
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
),
],
),
...members
.where(
(membership) =>
membership.content["membership"] ==
Membership.join.name,
)
.map(
(member) => ListTile(
leading: AvatarOrHash(
ref
.watch(
AvatarController.provider(
member.content["avatar_url"].toString(),
),
)
.whenOrNull(data: (data) => data),
member.content["displayname"].toString(),
headers: room.client.headers,
),
title: Text(
member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
);
}

View file

@ -0,0 +1,57 @@
import "package:flutter/material.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
final bool isDesktop;
final FullRoom room;
final void Function(BuildContext context) onOpenMemberList;
final void Function(BuildContext context) onOpenDrawer;
const RoomAppbar(
this.room, {
required this.isDesktop,
required this.onOpenMemberList,
required this.onOpenDrawer,
super.key,
});
@override
Size get preferredSize => AppBar().preferredSize;
@override
Widget build(BuildContext context) => Appbar(
leading: isDesktop
? AvatarOrHash(
room.avatar,
room.title,
height: 32,
fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
)
: DrawerButton(onPressed: () => onOpenDrawer(context)),
scrolledUnderElevation: 0,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(room.title, overflow: TextOverflow.ellipsis),
if (room.roomData.topic.isNotEmpty)
Text(
room.roomData.topic,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
IconButton(
onPressed: () => onOpenMemberList(context),
icon: Icon(Icons.people),
),
],
);
}

View file

@ -0,0 +1,317 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_link_previewer/flutter_link_previewer.dart";
import "package:flyer_chat_file_message/flyer_chat_file_message.dart";
import "package:flyer_chat_image_message/flyer_chat_image_message.dart";
import "package:flyer_chat_system_message/flyer_chat_system_message.dart";
import "package:flyer_chat_text_message/flyer_chat_text_message.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/chat_page/chat_box.dart";
import "package:nexus/widgets/chat_page/code_block.dart";
import "package:nexus/widgets/chat_page/member_list.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/spoiler_text.dart";
import "package:nexus/widgets/chat_page/top_widget.dart";
import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart";
class RoomChat extends HookConsumerWidget {
final bool isDesktop;
final bool showMembersByDefault;
const RoomChat({
required this.isDesktop,
required this.showMembersByDefault,
super.key,
});
void showContextMenu({
required BuildContext context,
required Offset globalPosition,
required VoidCallback onTap,
}) => showMenu(
context: context,
position: RelativeRect.fromRect(
Rect.fromPoints(globalPosition, globalPosition),
Offset.zero & (context.findRenderObject() as RenderBox).size,
),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
items: [
PopupMenuItem(
onTap: onTap,
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
],
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault);
final theme = Theme.of(context);
return ref
.watch(CurrentRoomController.provider)
.betterWhen(
data: (room) {
final controllerProvider = RoomChatController.provider(
room.roomData,
);
final notifier = ref.watch(controllerProvider.notifier);
return Scaffold(
appBar: RoomAppbar(
room,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
),
onMessageSecondaryTap:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
builders: Builders(
chatAnimatedListBuilder: (_, itemBuilder) =>
ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: notifier.loadOlder,
onStartReached: () => notifier.markRead(),
),
composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value,
onDismiss: () => replyToMessage.value = null,
headers: room.roomData.client.headers,
),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
customWidget: HtmlWidget(
message.metadata?["formatted"],
customWidgetBuilder: (element) {
if (element.localName == "mx-reply") {
return SizedBox.shrink();
}
if (element.localName == "code") {
if (element.parent?.localName ==
"pre") {
return CodeBlock(
element.text,
lang: element.className
.replaceAll("language-", ""),
);
}
}
if (element.localName == "img") {
final src = Uri.tryParse(
element.attributes["src"] ?? "",
);
if (src?.scheme != "mxc") {
return SizedBox.shrink();
}
// TODO: Should do something like:
// return Image.network(
// src!.getThumbnailUri(
// room.roomData.client,
// ),
// );
return SizedBox.shrink();
}
if (element.attributes.keys.contains(
"data-mx-spoiler",
)) {
return SpoilerText(
text: element.text,
);
}
return null;
},
customStylesBuilder: (element) => {
"width": "auto",
...Map.fromEntries(
element.attributes
.mapTo<MapEntry<String, String>?>(
(key, value) => switch (key) {
"data-mx-color" => MapEntry(
"color",
value,
),
"data-mx-bg-color" =>
MapEntry(
"background-color",
value,
),
_ => null,
},
)
.nonNulls,
),
},
onTapUrl: (url) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
),
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
showTime: true,
index: index,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text: message.text,
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) =>
notifier.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
headers: room.roomData.client.headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall
?.copyWith(color: Colors.grey),
)
: SizedBox.shrink(),
),
onMessageSend: (message) {
notifier.send(
message,
replyTo: replyToMessage.value,
);
replyToMessage.value = null;
},
resolveUser: notifier.resolveUser,
chatController: controller,
),
),
),
if (memberListOpened.value == true && showMembersByDefault)
MemberList(room.roomData),
],
),
endDrawer: showMembersByDefault
? null
: MemberList(room.roomData),
);
},
);
}
}

View file

@ -0,0 +1,139 @@
import "package:collection/collection.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/pages/settings_page.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class Sidebar extends HookConsumerWidget {
const Sidebar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedSpace = useState(0);
final selectedRoom = useState(0);
return Drawer(
shape: Border(),
child: Row(
children: [
ref
.watch(SpacesController.provider)
.when(
loading: SizedBox.shrink,
error: (error, stack) {
debugPrintStack(label: error.toString(), stackTrace: stack);
throw error;
},
data: (spaces) => NavigationRail(
scrollable: true,
onDestinationSelected: (value) {
selectedSpace.value = value;
selectedRoom.value = 0;
ref
.watch(CurrentRoomController.provider.notifier)
.set(spaces[selectedSpace.value].children[0]);
},
destinations: spaces
.map(
(space) => NavigationRailDestination(
icon: Badge(
smallSize: 8,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
isLabelVisible:
space.children.firstWhereOrNull(
(room) => room.roomData.hasNewMessages,
) !=
null,
child: AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
),
label: Text(space.title),
padding: EdgeInsets.only(top: 4),
),
)
.toList(),
selectedIndex: selectedSpace.value,
trailingAtBottom: true,
trailing: Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: IconButton(
onPressed: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => SettingsPage())),
icon: Icon(Icons.settings),
),
),
),
),
Expanded(
child: ref
.watch(SpacesController.provider)
.betterWhen(
data: (spaces) {
final space = spaces[selectedSpace.value];
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
leading: AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
title: Text(
space.title,
overflow: TextOverflow.ellipsis,
),
backgroundColor: Colors.transparent,
),
body: NavigationRail(
scrollable: true,
backgroundColor: Colors.transparent,
extended: true,
selectedIndex: space.children.isEmpty
? null
: selectedRoom.value,
destinations: space.children
.map(
(room) => NavigationRailDestination(
label: Text(room.title),
icon: Badge(
isLabelVisible: room.roomData.hasNewMessages,
child: AvatarOrHash(
room.avatar,
room.title,
fallback: selectedSpace.value == 1
? null
: Icon(Icons.numbers),
headers: space.client.headers,
),
),
),
)
.toList(),
onDestinationSelected: (value) {
selectedRoom.value = value;
ref
.watch(CurrentRoomController.provider.notifier)
.set(space.children[value]);
},
),
);
},
),
),
],
),
);
}
}

View file

@ -0,0 +1,29 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
class SpoilerText extends HookWidget {
final String text;
const SpoilerText({super.key, required this.text});
@override
Widget build(BuildContext context) {
final revealed = useState(false);
return InkWell(
onTap: () => revealed.value = !revealed.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: revealed.value ? Colors.transparent : Colors.blueGrey,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(color: revealed.value ? null : Colors.transparent),
),
),
);
}
}

View file

@ -0,0 +1,123 @@
import "dart:math";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/message_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
class TopWidget extends ConsumerWidget {
final Message message;
final Map<String, String> headers;
final MessageGroupStatus? groupStatus;
const TopWidget(
this.message, {
required this.headers,
required this.groupStatus,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.replyToMessageId != null) ...[
ref
.watch(MessageController.provider(message.replyToMessageId!))
.betterWhen(
loading: SizedBox.shrink,
data: (replyMessage) {
if (replyMessage == null) return SizedBox.shrink();
// Black magic to limit reply preview length
final replyText = message is TextMessage
? replyMessage.text.substring(
0,
min(
max(
min(
max(
(message as TextMessage).text.length - 20,
message.metadata?["displayName"].length,
),
replyMessage.text.length,
),
5,
),
replyMessage.text.length,
),
)
: replyMessage.text;
return InkWell(
onTap: () => showAboutDialog(context: context),
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
width: 4,
color: Theme.of(context).dividerColor,
),
),
),
child: Padding(
padding: EdgeInsets.only(left: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(
userId: replyMessage.authorId,
headers: headers,
size: 16,
),
Flexible(
child: Text(
replyMessage.metadata?["displayName"] ??
replyMessage.authorId,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
replyText,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
),
),
],
),
),
),
);
},
),
SizedBox(height: 12),
],
if (groupStatus?.isFirst != false)
InkWell(
onTap: () =>
showAboutDialog(context: context), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(userId: message.authorId, headers: headers),
Flexible(
child: Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
),
SizedBox(height: 4),
],
);
}