diff --git a/lib/controllers/current_room_controller.dart b/lib/controllers/current_room_controller.dart new file mode 100644 index 0000000..cb5656d --- /dev/null +++ b/lib/controllers/current_room_controller.dart @@ -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 { + @override + Future 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.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 6dae669..5dbd197 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,27 +1,132 @@ 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:matrix/matrix.dart"; -class RoomChatController extends Notifier { - RoomChatController(this.roomId); - final String roomId; +class RoomChatController extends AsyncNotifier { + RoomChatController(this.room); + final Room room; @override - InMemoryChatController build() => InMemoryChatController(); + Future 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) { - state.insertMessage( + Future insertMessage(Message message) async { + final controller = await future; + return controller.insertMessage(message); + } + + Future updateMessage(Message message, Message newMessage) async { + final controller = await future; + return controller.updateMessage(message, newMessage); + } + + Future 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 send(String message) async { + insertMessage( Message.text( id: DateTime.now().millisecondsSinceEpoch.toString(), - authorId: "foo", + authorId: room.client.userID!, text: message, ), ); + + await room.sendTextEvent(message); } - static final provider = - NotifierProvider.family( + Future resolveUser(String id) async { + 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.new, ); } diff --git a/lib/helpers/extension_helper.dart b/lib/helpers/extension_helper.dart index 1d9b8f8..739c602 100644 --- a/lib/helpers/extension_helper.dart +++ b/lib/helpers/extension_helper.dart @@ -20,16 +20,20 @@ extension BetterWhen on AsyncValue { extension GetFullRoom on Room { Future get fullRoom async { - final thumb = await avatar?.getThumbnailUri(client, width: 24, height: 24); return FullRoom( roomData: this, title: getLocalizedDisplayname(), - avatar: thumb == null - ? null - : Image.network( - thumb.toString(), - headers: {"authorization": "Bearer ${client.accessToken}"}, - ), + avatar: await avatar?.asImage(client), + ); + } +} + +extension GetImage on Uri { + Future asImage(Client client) async { + final thumb = await getThumbnailUri(client, width: 24, height: 24); + return Image.network( + thumb.toString(), + headers: {"authorization": "Bearer ${client.accessToken}"}, ); } } diff --git a/lib/main.dart b/lib/main.dart index 612a704..bbc3ba7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,14 @@ -import "dart:io"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/widgets/room_chat.dart"; import "package:nexus/widgets/sidebar.dart"; +import "package:scaled_app/scaled_app.dart"; import "package:window_manager/window_manager.dart"; import "package:flutter/material.dart"; import "package:dynamic_system_colors/dynamic_system_colors.dart"; import "package:window_size/window_size.dart"; void main() async { - WidgetsFlutterBinding.ensureInitialized(); + ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.4); await windowManager.ensureInitialized(); await windowManager.waitUntilReadyToShow( @@ -48,28 +48,7 @@ class App extends StatelessWidget { builder: (context) => Row( children: [ if (isDesktop) Sidebar(), - Expanded( - 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(), - ), - ), + Expanded(child: RoomChat(isDesktop: isDesktop)), ], ), ), diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 321d27f..786f8d0 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -1,92 +1,223 @@ +import "dart:io"; + +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_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"; class RoomChat extends HookConsumerWidget { - const RoomChat({super.key}); + final bool isDesktop; + const RoomChat({required this.isDesktop, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final urlRegex = RegExp(r"https?://[^\s\]\(\)]+"); - final controller = RoomChatController.provider("1"); final theme = Theme.of(context); - return Chat( - currentUserId: "foo", - theme: ChatTheme.fromThemeData(theme).copyWith( - colors: ChatColors.fromThemeData(theme).copyWith( - primary: theme.colorScheme.primaryContainer, - onPrimary: theme.colorScheme.onPrimaryContainer, - ), - ), - builders: Builders( - composerBuilder: (_) => Composer( - sendIconColor: theme.colorScheme.primary, - sendOnEnter: true, - ), - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatTextMessage( - message: message.copyWith( - text: message.text.replaceAllMapped( - urlRegex, - (match) => "[${match.group(0)}](${match.group(0)})", - ), + return ref + .watch(CurrentRoomController.provider) + .betterWhen( + data: (room) { + final controllerProvider = RoomChatController.provider( + room.roomData, + ); + final headers = { + "authorization": "Bearer ${room.roomData.client.accessToken}", + }; + return Scaffold( + appBar: AppBar( + leading: isDesktop + ? null + : DrawerButton(onPressed: Scaffold.of(context).openDrawer), + actionsPadding: EdgeInsets.symmetric(horizontal: 8), + title: Text(room.title), + actions: [ + if (!(Platform.isAndroid || Platform.isIOS)) + IconButton( + onPressed: () => exit(0), + icon: Icon(Icons.close), + ), + ], ), - 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(controller) - .updateMessage( - message, - message.copyWith(linkPreviewData: linkPreviewData), - ); + body: 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, + ), + ), + builders: Builders( + composerBuilder: (_) => Composer( + sendIconColor: theme.colorScheme.primary, + sendOnEnter: true, + autofocus: true, + ), + unsupportedMessageBuilder: + ( + _, + 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), - ); + ); } } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 63f4948..ee68344 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -1,6 +1,7 @@ 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/widgets/avatar.dart"; @@ -10,7 +11,8 @@ class Sidebar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final index = useState(0); + final selectedSpace = useState(0); + final selectedRoom = useState(0); return Drawer( shape: Border(), child: Row( @@ -25,7 +27,7 @@ class Sidebar extends HookConsumerWidget { }, data: (spaces) => NavigationRail( scrollable: true, - onDestinationSelected: (value) => index.value = value, + onDestinationSelected: (value) => selectedSpace.value = value, destinations: spaces .map( (space) => NavigationRailDestination( @@ -35,7 +37,7 @@ class Sidebar extends HookConsumerWidget { ), ) .toList(), - selectedIndex: index.value, + selectedIndex: selectedSpace.value, ), ), Expanded( @@ -43,7 +45,7 @@ class Sidebar extends HookConsumerWidget { .watch(SpacesController.provider) .betterWhen( data: (spaces) { - final space = spaces[index.value]; + final space = spaces[selectedSpace.value]; return Scaffold( backgroundColor: Colors.transparent, appBar: AppBar( @@ -71,7 +73,7 @@ class Sidebar extends HookConsumerWidget { icon: Avatar( room.avatar, room.title, - fallback: index.value == 1 + fallback: selectedSpace.value == 1 ? null : Icon(Icons.numbers), ), @@ -79,7 +81,15 @@ class Sidebar extends HookConsumerWidget { ), ) .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, ), ); }, diff --git a/pubspec.lock b/pubspec.lock index cc20fe7..85de9e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -582,6 +582,14 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -1166,6 +1174,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59a906a..da107c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,17 +40,19 @@ dependencies: url: https://github.com/google/flutter-desktop-embedding path: plugins/window_size flutter_chat_core: ^2.0.0 - flyer_chat_image_message: ^2.0.0 - flyer_chat_system_message: ^2.0.0 - flutter_link_previewer: ^4.0.0 - flyer_chat_text_message: ^2.0.0 + flyer_chat_image_message: ^2.2.2 + flyer_chat_system_message: ^2.1.13 + flyer_chat_text_message: ^2.5.2 + flyer_chat_file_message: ^2.3.1 flutter_chat_ui: git: url: https://github.com/Henry-Hiles/flutter_chat_ui path: packages/flutter_chat_ui + flutter_link_previewer: ^4.1.2 matrix: ^3.0.2 sqflite_common_ffi: ^2.3.6 color_hash: ^1.0.1 + scaled_app: ^2.3.0 dev_dependencies: build_runner: ^2.4.11