messages rendering

This commit is contained in:
Henry Hiles 2025-11-11 21:00:28 -05:00
commit d1f070e5c8
No known key found for this signature in database
8 changed files with 387 additions and 122 deletions

View file

@ -0,0 +1,18 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/full_room.dart";
class CurrentRoomController extends AsyncNotifier<FullRoom> {
@override
Future<FullRoom> build() async => (await ref.watch(
SpacesController.provider.future,
))[0].children[0].roomData.fullRoom;
void set(FullRoom room) => state = AsyncValue.data(room);
static final provider =
AsyncNotifierProvider<CurrentRoomController, FullRoom>(
CurrentRoomController.new,
);
}

View file

@ -1,27 +1,132 @@
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class RoomChatController extends Notifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
RoomChatController(this.roomId); RoomChatController(this.room);
final String roomId; final Room room;
@override @override
InMemoryChatController build() => InMemoryChatController(); Future<ChatController> build() async {
final timeline = await room.getTimeline();
// void setRoom(Room room) => state = (await ref.watch(ClientController.provider.future)); final controller = InMemoryChatController(
messages: (await Future.wait(
timeline.events.map(toMessage),
)).toList().reversed.nonNulls.toList(),
);
return controller;
}
void send(String message) { Future<void> insertMessage(Message message) async {
state.insertMessage( final controller = await future;
return controller.insertMessage(message);
}
Future<void> updateMessage(Message message, Message newMessage) async {
final controller = await future;
return controller.updateMessage(message, newMessage);
}
Future<Message?> toMessage(Event event) async {
final replyId = event.relationshipType == RelationshipTypes.reply
? event.relationshipEventId
: null;
final metadata = {
"eventType": event.type,
"displayName": event.senderFromMemoryOrFallback.displayName,
};
return event.redacted
? Message.text(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: "~~This message has been redacted.~~",
deletedAt: event.redactedBecause?.originServerTs,
)
: switch (event.type) {
EventTypes.Message => switch (event.messageType) {
MessageTypes.Image => Message.image(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
source: (await event.getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
MessageTypes.Audio => Message.audio(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: event.body,
replyToMessageId: replyId,
source: (await event.getAttachmentUri()).toString(),
deliveredAt: event.originServerTs,
duration: Duration(hours: 1),
),
MessageTypes.File => Message.file(
name: event.content["filename"].toString(),
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
source: (await event.getAttachmentUri()).toString(),
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
_ => Message.text(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text: event.body,
replyToMessageId: replyId,
deliveredAt: event.originServerTs,
),
},
EventTypes.RoomMember => Message.system(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
text:
"${event.senderFromMemoryOrFallback.calcDisplayname()} joined the room.",
),
EventTypes.Redaction => null,
_ => Message.unsupported(
metadata: metadata,
id: event.eventId,
authorId: event.senderId,
replyToMessageId: replyId,
),
};
}
Future<void> send(String message) async {
insertMessage(
Message.text( Message.text(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
authorId: "foo", authorId: room.client.userID!,
text: message, text: message,
), ),
); );
await room.sendTextEvent(message);
} }
static final provider = Future<chat.User> resolveUser(String id) async {
NotifierProvider.family<RoomChatController, ChatController, String>( final user = await room.client.getUserProfile(id);
return chat.User(
id: id,
name: user.displayname,
imageSource: (await user.avatarUrl?.getThumbnailUri(
room.client,
width: 24,
height: 24,
))?.toString(),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, ChatController, Room>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -20,16 +20,20 @@ extension BetterWhen<T> on AsyncValue<T> {
extension GetFullRoom on Room { extension GetFullRoom on Room {
Future<FullRoom> get fullRoom async { Future<FullRoom> get fullRoom async {
final thumb = await avatar?.getThumbnailUri(client, width: 24, height: 24);
return FullRoom( return FullRoom(
roomData: this, roomData: this,
title: getLocalizedDisplayname(), title: getLocalizedDisplayname(),
avatar: thumb == null avatar: await avatar?.asImage(client),
? null );
: Image.network( }
thumb.toString(), }
headers: {"authorization": "Bearer ${client.accessToken}"},
), extension GetImage on Uri {
Future<Image?> asImage(Client client) async {
final thumb = await getThumbnailUri(client, width: 24, height: 24);
return Image.network(
thumb.toString(),
headers: {"authorization": "Bearer ${client.accessToken}"},
); );
} }
} }

View file

@ -1,14 +1,14 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/room_chat.dart"; import "package:nexus/widgets/room_chat.dart";
import "package:nexus/widgets/sidebar.dart"; import "package:nexus/widgets/sidebar.dart";
import "package:scaled_app/scaled_app.dart";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:dynamic_system_colors/dynamic_system_colors.dart"; import "package:dynamic_system_colors/dynamic_system_colors.dart";
import "package:window_size/window_size.dart"; import "package:window_size/window_size.dart";
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.4);
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
await windowManager.waitUntilReadyToShow( await windowManager.waitUntilReadyToShow(
@ -48,28 +48,7 @@ class App extends StatelessWidget {
builder: (context) => Row( builder: (context) => Row(
children: [ children: [
if (isDesktop) Sidebar(), if (isDesktop) Sidebar(),
Expanded( Expanded(child: RoomChat(isDesktop: isDesktop)),
child: Scaffold(
appBar: AppBar(
leading: isDesktop
? null
: DrawerButton(
onPressed: () =>
Scaffold.of(context).openDrawer(),
),
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: Text("Some Chat Name"),
actions: [
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(
onPressed: () => exit(0),
icon: Icon(Icons.close),
),
],
),
body: RoomChat(),
),
),
], ],
), ),
), ),

View file

@ -1,92 +1,223 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart"; import "package:flutter_chat_ui/flutter_chat_ui.dart";
import "package:flutter_link_previewer/flutter_link_previewer.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_image_message/flyer_chat_image_message.dart";
import "package:flyer_chat_system_message/flyer_chat_system_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:flyer_chat_text_message/flyer_chat_text_message.dart";
import "package:hooks_riverpod/hooks_riverpod.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/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
const RoomChat({super.key}); final bool isDesktop;
const RoomChat({required this.isDesktop, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final urlRegex = RegExp(r"https?://[^\s\]\(\)]+"); final urlRegex = RegExp(r"https?://[^\s\]\(\)]+");
final controller = RoomChatController.provider("1");
final theme = Theme.of(context); final theme = Theme.of(context);
return Chat( return ref
currentUserId: "foo", .watch(CurrentRoomController.provider)
theme: ChatTheme.fromThemeData(theme).copyWith( .betterWhen(
colors: ChatColors.fromThemeData(theme).copyWith( data: (room) {
primary: theme.colorScheme.primaryContainer, final controllerProvider = RoomChatController.provider(
onPrimary: theme.colorScheme.onPrimaryContainer, room.roomData,
), );
), final headers = {
builders: Builders( "authorization": "Bearer ${room.roomData.client.accessToken}",
composerBuilder: (_) => Composer( };
sendIconColor: theme.colorScheme.primary, return Scaffold(
sendOnEnter: true, appBar: AppBar(
), leading: isDesktop
textMessageBuilder: ? null
( : DrawerButton(onPressed: Scaffold.of(context).openDrawer),
context, actionsPadding: EdgeInsets.symmetric(horizontal: 8),
message, title: Text(room.title),
index, { actions: [
required bool isSentByMe, if (!(Platform.isAndroid || Platform.isIOS))
MessageGroupStatus? groupStatus, IconButton(
}) => FlyerChatTextMessage( onPressed: () => exit(0),
message: message.copyWith( icon: Icon(Icons.close),
text: message.text.replaceAllMapped( ),
urlRegex, ],
(match) => "[${match.group(0)}](${match.group(0)})",
),
), ),
index: index, body: ref
onLinkTap: (url, _) => .watch(controllerProvider)
ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(url)), .betterWhen(
linksDecoration: TextDecoration.underline, data: (controller) => Chat(
sentLinksColor: Colors.blue, currentUserId: room.roomData.client.userID!,
receivedLinksColor: Colors.blue, theme: ChatTheme.fromThemeData(theme).copyWith(
), colors: ChatColors.fromThemeData(theme).copyWith(
linkPreviewBuilder: (_, message, isSentByMe) => LinkPreview( primary: theme.colorScheme.primaryContainer,
text: urlRegex.firstMatch(message.text)?.group(0) ?? "", onPrimary: theme.colorScheme.onPrimaryContainer,
backgroundColor: isSentByMe ),
? theme.colorScheme.inversePrimary ),
: theme.colorScheme.surfaceContainerLow, builders: Builders(
insidePadding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), composerBuilder: (_) => Composer(
linkPreviewData: message.linkPreviewData, sendIconColor: theme.colorScheme.primary,
onLinkPreviewDataFetched: (linkPreviewData) { sendOnEnter: true,
ref autofocus: true,
.watch(controller) ),
.updateMessage( unsupportedMessageBuilder:
message, (
message.copyWith(linkPreviewData: linkPreviewData), _,
); message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? FlyerChatTextMessage(
message: TextMessage(
id: message.id,
authorId: message.authorId,
text:
"Unsupported message type: ${message.metadata?["eventType"]}",
),
receivedBackgroundColor: Colors.red,
sentBackgroundColor: Colors.red,
index: index,
)
: SizedBox.shrink(),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => Column(
crossAxisAlignment: isSentByMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
spacing: 8,
children: [
SizedBox(height: 8),
FlyerChatTextMessage(
topWidget: Padding(
padding: EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(
userId: message.authorId,
headers: headers,
),
Text(
message.metadata?["displayName"] ??
message.authorId,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
(match) =>
"[${match.group(0)}](${match.group(0)})",
),
),
showTime: true,
index: index,
onLinkTap: (url, _) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
linksDecoration: TextDecoration.underline,
sentLinksColor: Colors.blue,
receivedLinksColor: Colors.blue,
),
],
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text:
urlRegex.firstMatch(message.text)?.group(0) ??
"",
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched: (linkPreviewData) => ref
.watch(controllerProvider.notifier)
.updateMessage(
message,
message.copyWith(
linkPreviewData: linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
message: message,
index: index,
headers: headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
),
onMessageSend: ref
.watch(controllerProvider.notifier)
.send,
resolveUser: ref
.watch(controllerProvider.notifier)
.resolveUser,
chatController: controller,
),
),
);
}, },
), );
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(message: message, index: index),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(message: message, index: index),
),
onMessageSend: ref.watch(controller.notifier).send,
resolveUser: (id) async => User(id: id, imageSource: "foo"),
chatController: ref.watch(controller),
);
} }
} }

View file

@ -1,6 +1,7 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.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/controllers/spaces_controller.dart";
import "package:nexus/helpers/extension_helper.dart"; import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/avatar.dart"; import "package:nexus/widgets/avatar.dart";
@ -10,7 +11,8 @@ class Sidebar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final index = useState(0); final selectedSpace = useState(0);
final selectedRoom = useState(0);
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Row( child: Row(
@ -25,7 +27,7 @@ class Sidebar extends HookConsumerWidget {
}, },
data: (spaces) => NavigationRail( data: (spaces) => NavigationRail(
scrollable: true, scrollable: true,
onDestinationSelected: (value) => index.value = value, onDestinationSelected: (value) => selectedSpace.value = value,
destinations: spaces destinations: spaces
.map( .map(
(space) => NavigationRailDestination( (space) => NavigationRailDestination(
@ -35,7 +37,7 @@ class Sidebar extends HookConsumerWidget {
), ),
) )
.toList(), .toList(),
selectedIndex: index.value, selectedIndex: selectedSpace.value,
), ),
), ),
Expanded( Expanded(
@ -43,7 +45,7 @@ class Sidebar extends HookConsumerWidget {
.watch(SpacesController.provider) .watch(SpacesController.provider)
.betterWhen( .betterWhen(
data: (spaces) { data: (spaces) {
final space = spaces[index.value]; final space = spaces[selectedSpace.value];
return Scaffold( return Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
appBar: AppBar( appBar: AppBar(
@ -71,7 +73,7 @@ class Sidebar extends HookConsumerWidget {
icon: Avatar( icon: Avatar(
room.avatar, room.avatar,
room.title, room.title,
fallback: index.value == 1 fallback: selectedSpace.value == 1
? null ? null
: Icon(Icons.numbers), : Icon(Icons.numbers),
), ),
@ -79,7 +81,15 @@ class Sidebar extends HookConsumerWidget {
), ),
) )
.toList(), .toList(),
selectedIndex: space.children.isEmpty ? null : 0, onDestinationSelected: (value) {
selectedRoom.value = value;
ref
.watch(CurrentRoomController.provider.notifier)
.set(space.children[value]);
},
selectedIndex: space.children.isEmpty
? null
: selectedRoom.value,
), ),
); );
}, },

View file

@ -582,6 +582,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flyer_chat_file_message:
dependency: "direct main"
description:
name: flyer_chat_file_message
sha256: "9d3e40819ebd3a32c6821e32a54caf7675af80dd05ce679f8113277f2379ecf4"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
flyer_chat_image_message: flyer_chat_image_message:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1166,6 +1174,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.28.0" version: "0.28.0"
scaled_app:
dependency: "direct main"
description:
name: scaled_app
sha256: a2ad9f22cf2200a5ce455b59c5ea7bfb09a84acfc52452d1db54f4958c99d76a
url: "https://pub.dev"
source: hosted
version: "2.3.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:

View file

@ -40,17 +40,19 @@ dependencies:
url: https://github.com/google/flutter-desktop-embedding url: https://github.com/google/flutter-desktop-embedding
path: plugins/window_size path: plugins/window_size
flutter_chat_core: ^2.0.0 flutter_chat_core: ^2.0.0
flyer_chat_image_message: ^2.0.0 flyer_chat_image_message: ^2.2.2
flyer_chat_system_message: ^2.0.0 flyer_chat_system_message: ^2.1.13
flutter_link_previewer: ^4.0.0 flyer_chat_text_message: ^2.5.2
flyer_chat_text_message: ^2.0.0 flyer_chat_file_message: ^2.3.1
flutter_chat_ui: flutter_chat_ui:
git: git:
url: https://github.com/Henry-Hiles/flutter_chat_ui url: https://github.com/Henry-Hiles/flutter_chat_ui
path: packages/flutter_chat_ui path: packages/flutter_chat_ui
flutter_link_previewer: ^4.1.2
matrix: ^3.0.2 matrix: ^3.0.2
sqflite_common_ffi: ^2.3.6 sqflite_common_ffi: ^2.3.6
color_hash: ^1.0.1 color_hash: ^1.0.1
scaled_app: ^2.3.0
dev_dependencies: dev_dependencies:
build_runner: ^2.4.11 build_runner: ^2.4.11