diff --git a/lib/controllers/avatar_controller.dart b/lib/controllers/avatar_controller.dart new file mode 100644 index 0000000..1bb4c72 --- /dev/null +++ b/lib/controllers/avatar_controller.dart @@ -0,0 +1,17 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/client_controller.dart"; + +class AvatarController extends AsyncNotifier { + final String mxc; + AvatarController(this.mxc); + @override + Future build() async => Uri.parse(mxc).getThumbnailUri( + await ref.watch(ClientController.provider.future), + width: 24, + height: 24, + ); + + static final provider = AsyncNotifierProvider.family + .autoDispose(AvatarController.new); +} diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index 08ff185..4970201 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -1,5 +1,4 @@ import "dart:io"; - import "package:flutter/foundation.dart"; import "package:matrix/matrix.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -29,6 +28,8 @@ class ClientController extends AsyncNotifier { if (client.accessToken == null) { await client.login( LoginType.mLoginPassword, + initialDeviceDisplayName: "Nexus Client", + deviceId: "temp", // TODO identifier: AuthenticationUserIdentifier(user: "quadradical"), password: File("./password.txt").readAsStringSync(), ); diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart new file mode 100644 index 0000000..f362e64 --- /dev/null +++ b/lib/controllers/members_controller.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; + +class MembersController extends AsyncNotifier> { + final Room room; + MembersController(this.room); + + @override + Future> build() async => + (await room.client.getMembersByRoom( + room.id, + notMembership: Membership.leave, + )) ?? + []; + + static final provider = + AsyncNotifierProvider.family, Room>( + MembersController.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index d81bf4a..bf0eabe 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -3,22 +3,26 @@ 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"; +import "package:nexus/controllers/timeline_controller.dart"; import "package:nexus/helpers/extension_helper.dart"; class RoomChatController extends AsyncNotifier { - RoomChatController(this.room); final Room room; + RoomChatController(this.room); @override Future build() async { - final timeline = await room.getTimeline(); - room.client.onTimelineEvent.stream.listen((event) async { - if (event.roomId != room.id) return; - final message = await event.toMessage(); - if (message != null) { - await insertMessage(message); - } - }); + final timeline = await ref.watch(TimelineController.provider(room).future); + + ref.onDispose( + room.client.onTimelineEvent.stream.listen((event) async { + if (event.roomId != room.id) return; + final message = await event.toMessage(); + if (message != null) { + await insertMessage(message); + } + }).cancel, + ); return InMemoryChatController( messages: (await Future.wait( @@ -29,15 +33,22 @@ class RoomChatController extends AsyncNotifier { Future insertMessage(Message message) async { final controller = await future; - final oldMessage = controller.messages.firstWhereOrNull( - (element) => element.metadata?["txnId"] == message.metadata?["txnId"], - ); + final oldMessage = message.metadata?["txnId"] == null + ? null + : controller.messages.firstWhereOrNull( + (element) => + element.metadata?["txnId"] == message.metadata?["txnId"], + ); return oldMessage == null ? controller.insertMessage(message) : controller.updateMessage(oldMessage, message); } + Future loadOlder() async { + await ref.watch(TimelineController.provider(room).notifier).prev(); + } + Future updateMessage(Message message, Message newMessage) async { final controller = await future; return controller.updateMessage(message, newMessage); @@ -55,6 +66,7 @@ class RoomChatController extends AsyncNotifier { id: id, name: user.displayname, imageSource: (await user.avatarUrl?.getThumbnailUri( + // TODO: Fix use of account avatar not room avatar room.client, width: 24, height: 24, diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index 0e13075..76b0e8c 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -30,24 +30,27 @@ class SpacesController extends AsyncNotifier> { return [ Space( + client: client, title: "Home", children: topLevelRooms, - avatar: Icon(Icons.home), + icon: Icon(Icons.home), fake: true, ), Space( + client: client, title: "Direct Messages", children: await Future.wait( client.rooms .where((room) => room.isDirectChat) .map((room) => room.fullRoom), ), - avatar: Icon(Icons.person), + icon: Icon(Icons.person), fake: true, ), ...(await Future.wait( topLevelSpaces.map( (space) async => Space( + client: client, title: space.title, avatar: space.avatar, children: await Future.wait( diff --git a/lib/controllers/timeline_controller.dart b/lib/controllers/timeline_controller.dart new file mode 100644 index 0000000..447482d --- /dev/null +++ b/lib/controllers/timeline_controller.dart @@ -0,0 +1,21 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:matrix/matrix.dart"; + +class TimelineController extends AsyncNotifier { + TimelineController(this.room); + final Room room; + + @override + Future build() => room.getTimeline(); + + Future prev() async { + final timeline = await future; + await timeline.requestHistory(); + state = AsyncValue.data(timeline); + } + + static final provider = + AsyncNotifierProvider.family( + TimelineController.new, + ); +} diff --git a/lib/helpers/extension_helper.dart b/lib/helpers/extension_helper.dart index edd041b..22a65fd 100644 --- a/lib/helpers/extension_helper.dart +++ b/lib/helpers/extension_helper.dart @@ -21,23 +21,15 @@ extension BetterWhen on AsyncValue { } extension GetFullRoom on Room { - Future get fullRoom async { - return FullRoom( - roomData: this, - title: getLocalizedDisplayname(), - avatar: await avatar?.asImage(client), - ); - } + Future get fullRoom async => FullRoom( + roomData: this, + title: getLocalizedDisplayname(), + avatar: await avatar?.getThumbnailUri(client, width: 24, height: 24), + ); } -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}"}, - ); - } +extension GetHeaders on Client { + Map get headers => {"authorization": "Bearer $accessToken"}; } extension ToMessage on Event { diff --git a/lib/models/full_room.dart b/lib/models/full_room.dart index ad951d6..671a767 100644 --- a/lib/models/full_room.dart +++ b/lib/models/full_room.dart @@ -1,4 +1,3 @@ -import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:matrix/matrix.dart"; part "full_room.freezed.dart"; @@ -8,6 +7,6 @@ abstract class FullRoom with _$FullRoom { const factory FullRoom({ required Room roomData, required String title, - required Image? avatar, + required Uri? avatar, }) = _FullRoom; } diff --git a/lib/models/full_room.freezed.dart b/lib/models/full_room.freezed.dart index 2e42537..378d228 100644 --- a/lib/models/full_room.freezed.dart +++ b/lib/models/full_room.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$FullRoom { - Room get roomData; String get title; Image? get avatar; + Room get roomData; String get title; Uri? get avatar; /// Create a copy of FullRoom /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -45,7 +45,7 @@ abstract mixin class $FullRoomCopyWith<$Res> { factory $FullRoomCopyWith(FullRoom value, $Res Function(FullRoom) _then) = _$FullRoomCopyWithImpl; @useResult $Res call({ - Room roomData, String title, Image? avatar + Room roomData, String title, Uri? avatar }); @@ -67,7 +67,7 @@ class _$FullRoomCopyWithImpl<$Res> roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable -as Image?, +as Uri?, )); } @@ -152,7 +152,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( Room roomData, String title, Image? avatar)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( Room roomData, String title, Uri? avatar)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _FullRoom() when $default != null: return $default(_that.roomData,_that.title,_that.avatar);case _: @@ -173,7 +173,7 @@ return $default(_that.roomData,_that.title,_that.avatar);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( Room roomData, String title, Image? avatar) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( Room roomData, String title, Uri? avatar) $default,) {final _that = this; switch (_that) { case _FullRoom(): return $default(_that.roomData,_that.title,_that.avatar);case _: @@ -193,7 +193,7 @@ return $default(_that.roomData,_that.title,_that.avatar);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( Room roomData, String title, Image? avatar)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Room roomData, String title, Uri? avatar)? $default,) {final _that = this; switch (_that) { case _FullRoom() when $default != null: return $default(_that.roomData,_that.title,_that.avatar);case _: @@ -213,7 +213,7 @@ class _FullRoom implements FullRoom { @override final Room roomData; @override final String title; -@override final Image? avatar; +@override final Uri? avatar; /// Create a copy of FullRoom /// with the given fields replaced by the non-null parameter values. @@ -245,7 +245,7 @@ abstract mixin class _$FullRoomCopyWith<$Res> implements $FullRoomCopyWith<$Res> factory _$FullRoomCopyWith(_FullRoom value, $Res Function(_FullRoom) _then) = __$FullRoomCopyWithImpl; @override @useResult $Res call({ - Room roomData, String title, Image? avatar + Room roomData, String title, Uri? avatar }); @@ -267,7 +267,7 @@ class __$FullRoomCopyWithImpl<$Res> roomData: null == roomData ? _self.roomData : roomData // ignore: cast_nullable_to_non_nullable as Room,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable -as Image?, +as Uri?, )); } diff --git a/lib/models/space.dart b/lib/models/space.dart index be10bcc..7feeba5 100644 --- a/lib/models/space.dart +++ b/lib/models/space.dart @@ -1,5 +1,6 @@ import "package:flutter/widgets.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:matrix/matrix.dart"; import "package:nexus/models/full_room.dart"; part "space.freezed.dart"; @@ -7,8 +8,10 @@ part "space.freezed.dart"; abstract class Space with _$Space { const factory Space({ required String title, - required Widget? avatar, required List children, + required Client client, @Default(false) bool fake, + Uri? avatar, + Icon? icon, }) = _Space; } diff --git a/lib/models/space.freezed.dart b/lib/models/space.freezed.dart index 31439d0..c733e07 100644 --- a/lib/models/space.freezed.dart +++ b/lib/models/space.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$Space { - String get title; Widget? get avatar; List get children; bool get fake; + String get title; List get children; Client get client; bool get fake; Uri? get avatar; Icon? get icon; /// Create a copy of Space /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $SpaceCopyWith get copyWith => _$SpaceCopyWithImpl(this as Space, @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Space&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&const DeepCollectionEquality().equals(other.children, children)&&(identical(other.fake, fake) || other.fake == fake)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Space&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other.children, children)&&(identical(other.client, client) || other.client == client)&&(identical(other.fake, fake) || other.fake == fake)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.icon, icon) || other.icon == icon)); } @override -int get hashCode => Object.hash(runtimeType,title,avatar,const DeepCollectionEquality().hash(children),fake); +int get hashCode => Object.hash(runtimeType,title,const DeepCollectionEquality().hash(children),client,fake,avatar,icon); @override String toString() { - return 'Space(title: $title, avatar: $avatar, children: $children, fake: $fake)'; + return 'Space(title: $title, children: $children, client: $client, fake: $fake, avatar: $avatar, icon: $icon)'; } @@ -45,7 +45,7 @@ abstract mixin class $SpaceCopyWith<$Res> { factory $SpaceCopyWith(Space value, $Res Function(Space) _then) = _$SpaceCopyWithImpl; @useResult $Res call({ - String title, Widget? avatar, List children, bool fake + String title, List children, Client client, bool fake, Uri? avatar, Icon? icon }); @@ -62,13 +62,15 @@ class _$SpaceCopyWithImpl<$Res> /// Create a copy of Space /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? avatar = freezed,Object? children = null,Object? fake = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? children = null,Object? client = null,Object? fake = null,Object? avatar = freezed,Object? icon = freezed,}) { return _then(_self.copyWith( title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable -as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable -as Widget?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable -as List,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable -as bool, +as String,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable +as List,client: null == client ? _self.client : client // ignore: cast_nullable_to_non_nullable +as Client,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable +as bool,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as Uri?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as Icon?, )); } @@ -153,10 +155,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String title, Widget? avatar, List children, bool fake)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String title, List children, Client client, bool fake, Uri? avatar, Icon? icon)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _Space() when $default != null: -return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: +return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _: return orElse(); } @@ -174,10 +176,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String title, Widget? avatar, List children, bool fake) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String title, List children, Client client, bool fake, Uri? avatar, Icon? icon) $default,) {final _that = this; switch (_that) { case _Space(): -return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: +return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _: throw StateError('Unexpected subclass'); } @@ -194,10 +196,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String title, Widget? avatar, List children, bool fake)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String title, List children, Client client, bool fake, Uri? avatar, Icon? icon)? $default,) {final _that = this; switch (_that) { case _Space() when $default != null: -return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: +return $default(_that.title,_that.children,_that.client,_that.fake,_that.avatar,_that.icon);case _: return null; } @@ -209,11 +211,10 @@ return $default(_that.title,_that.avatar,_that.children,_that.fake);case _: class _Space implements Space { - const _Space({required this.title, required this.avatar, required final List children, this.fake = false}): _children = children; + const _Space({required this.title, required final List children, required this.client, this.fake = false, this.avatar, this.icon}): _children = children; @override final String title; -@override final Widget? avatar; final List _children; @override List get children { if (_children is EqualUnmodifiableListView) return _children; @@ -221,7 +222,10 @@ class _Space implements Space { return EqualUnmodifiableListView(_children); } +@override final Client client; @override@JsonKey() final bool fake; +@override final Uri? avatar; +@override final Icon? icon; /// Create a copy of Space /// with the given fields replaced by the non-null parameter values. @@ -233,16 +237,16 @@ _$SpaceCopyWith<_Space> get copyWith => __$SpaceCopyWithImpl<_Space>(this, _$ide @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Space&&(identical(other.title, title) || other.title == title)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&const DeepCollectionEquality().equals(other._children, _children)&&(identical(other.fake, fake) || other.fake == fake)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Space&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other._children, _children)&&(identical(other.client, client) || other.client == client)&&(identical(other.fake, fake) || other.fake == fake)&&(identical(other.avatar, avatar) || other.avatar == avatar)&&(identical(other.icon, icon) || other.icon == icon)); } @override -int get hashCode => Object.hash(runtimeType,title,avatar,const DeepCollectionEquality().hash(_children),fake); +int get hashCode => Object.hash(runtimeType,title,const DeepCollectionEquality().hash(_children),client,fake,avatar,icon); @override String toString() { - return 'Space(title: $title, avatar: $avatar, children: $children, fake: $fake)'; + return 'Space(title: $title, children: $children, client: $client, fake: $fake, avatar: $avatar, icon: $icon)'; } @@ -253,7 +257,7 @@ abstract mixin class _$SpaceCopyWith<$Res> implements $SpaceCopyWith<$Res> { factory _$SpaceCopyWith(_Space value, $Res Function(_Space) _then) = __$SpaceCopyWithImpl; @override @useResult $Res call({ - String title, Widget? avatar, List children, bool fake + String title, List children, Client client, bool fake, Uri? avatar, Icon? icon }); @@ -270,13 +274,15 @@ class __$SpaceCopyWithImpl<$Res> /// Create a copy of Space /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? avatar = freezed,Object? children = null,Object? fake = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? children = null,Object? client = null,Object? fake = null,Object? avatar = freezed,Object? icon = freezed,}) { return _then(_Space( title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable -as String,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable -as Widget?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable -as List,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable -as bool, +as String,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable +as List,client: null == client ? _self.client : client // ignore: cast_nullable_to_non_nullable +as Client,fake: null == fake ? _self.fake : fake // ignore: cast_nullable_to_non_nullable +as bool,avatar: freezed == avatar ? _self.avatar : avatar // ignore: cast_nullable_to_non_nullable +as Uri?,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable +as Icon?, )); } diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart new file mode 100644 index 0000000..23d1bdd --- /dev/null +++ b/lib/widgets/avatar_or_hash.dart @@ -0,0 +1,38 @@ +import "package:color_hash/color_hash.dart"; +import "package:flutter/widgets.dart"; + +class AvatarOrHash extends StatelessWidget { + final Uri? avatar; + final String title; + final Widget? fallback; + final Map headers; + const AvatarOrHash( + this.avatar, + this.title, { + this.fallback, + required this.headers, + super.key, + }); + + @override + Widget build(BuildContext context) { + final box = ColoredBox( + color: ColorHash(title).color, + child: Center(child: Text(title[0])), + ); + return ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(4)), + child: SizedBox( + width: 24, + height: 24, + child: avatar == null + ? fallback ?? box + : Image.network( + avatar.toString(), + headers: headers, + errorBuilder: (_, _, _) => box, + ), + ), + ); + } +} diff --git a/lib/widgets/chat_box.dart b/lib/widgets/chat_box.dart new file mode 100644 index 0000000..350ce16 --- /dev/null +++ b/lib/widgets/chat_box.dart @@ -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 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, + ); +} diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart new file mode 100644 index 0000000..9b47cc0 --- /dev/null +++ b/lib/widgets/member_list.dart @@ -0,0 +1,53 @@ +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) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: SizedBox( + width: 240, + child: ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( + children: [ + ...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, + ), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart new file mode 100644 index 0000000..8628c6c --- /dev/null +++ b/lib/widgets/room_appbar.dart @@ -0,0 +1,66 @@ +import "dart:io"; + +import "package:flutter/material.dart"; +import "package:nexus/helpers/extension_helper.dart"; +import "package:nexus/models/full_room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { + final bool isDesktop; + final FullRoom room; + final VoidCallback onOpenMemberList; + final VoidCallback onOpenDrawer; + const RoomAppbar( + this.room, { + required this.isDesktop, + required this.onOpenMemberList, + required this.onOpenDrawer, + super.key, + }); + + @override + Size get preferredSize => Size.fromHeight(AppBar().preferredSize.height + 16); + + @override + AppBar build(BuildContext context) => AppBar( + bottom: PreferredSize( + preferredSize: Size.zero, // Does this even matter?? + child: Row( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.all(8).copyWith(top: 0), + child: Text( + room.roomData.topic, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ), + leading: isDesktop ? null : DrawerButton(onPressed: onOpenDrawer), + actionsPadding: EdgeInsets.symmetric(horizontal: 8), + title: Row( + children: [ + AvatarOrHash( + room.avatar, + room.title, + fallback: Icon(Icons.numbers), + headers: room.roomData.client.headers, + ), + SizedBox(width: 12), + Expanded(child: Text(room.title, overflow: TextOverflow.ellipsis)), + ], + ), + actions: [ + IconButton(onPressed: onOpenMemberList, icon: Icon(Icons.people)), + if (!(Platform.isAndroid || Platform.isIOS)) + IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)), + ], + ); +} diff --git a/lib/widgets/room_avatar.dart b/lib/widgets/room_avatar.dart deleted file mode 100644 index a146be3..0000000 --- a/lib/widgets/room_avatar.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:color_hash/color_hash.dart"; -import "package:flutter/widgets.dart"; - -class RoomAvatar extends StatelessWidget { - final Widget? avatar; - final String title; - final Widget? fallback; - const RoomAvatar(this.avatar, this.title, {this.fallback, super.key}); - - @override - Widget build(BuildContext context) => ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(4)), - child: SizedBox( - width: 24, - height: 24, - child: - avatar ?? - fallback ?? - ColoredBox( - color: ColorHash(title).color, - child: Center(child: Text(title[0])), - ), - ), - ); -} diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 5142a53..cd3ab7f 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -1,5 +1,3 @@ -import "dart:io"; - import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; @@ -15,8 +13,10 @@ 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_box.dart"; +import "package:nexus/widgets/member_list.dart"; +import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/top_widget.dart"; -import "package:nexus/widgets/room_avatar.dart"; class RoomChat extends HookConsumerWidget { final bool isDesktop; @@ -33,12 +33,18 @@ class RoomChat extends HookConsumerWidget { Offset.zero & (context.findRenderObject() as RenderBox).size, ), color: Theme.of(context).colorScheme.surfaceContainerHighest, - items: [PopupMenuItem(onTap: onTap, child: Text("Reply"))], + items: [ + PopupMenuItem( + onTap: onTap, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + ], ); @override Widget build(BuildContext context, WidgetRef ref) { final replyToMessage = useState(null); + final memberListOpened = useState(isDesktop); final urlRegex = RegExp(r"https?://[^\s\]\(\)]+"); final theme = Theme.of(context); return ref @@ -48,250 +54,200 @@ class RoomChat extends HookConsumerWidget { 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: Row( - children: [ - RoomAvatar( - room.avatar, - room.title, - fallback: Icon(Icons.numbers), - ), - SizedBox(width: 12), - Expanded( - child: Text(room.title, overflow: TextOverflow.ellipsis), - ), - ], - ), - actions: [ - if (!(Platform.isAndroid || Platform.isIOS)) - IconButton( - onPressed: () => exit(0), - icon: Icon(Icons.close), - ), - ], + appBar: RoomAppbar( + room, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: () => + memberListOpened.value = !memberListOpened.value, ), - 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, - ), - ), - 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( - composerBuilder: (_) => Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (replyToMessage.value != null) - ColoredBox( - color: theme.colorScheme.surfaceContainer, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - child: Row( - spacing: 8, - children: [ - Avatar( - userId: replyToMessage.value!.authorId, - headers: headers, - size: 16, - ), - Text( - replyToMessage - .value! - .metadata?["displayName"] ?? - replyToMessage.value!.authorId, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Expanded( - child: (replyToMessage.value as dynamic) - ? Text( - (replyToMessage.value - as TextMessage) - .text, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.labelMedium, - maxLines: 1, - ) - : SizedBox(), - ), - IconButton( - onPressed: () => - replyToMessage.value = null, - icon: Icon(Icons.close), - iconSize: 20, - ), - ], - ), - ), + 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, ), - Composer( - sigmaX: 0, - sigmaY: 0, - sendIconColor: theme.colorScheme.primary, - sendOnEnter: true, - autofocus: true, ), - ], - ), - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => kDebugMode - ? Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall?.copyWith( - color: Colors.grey, + onMessageSecondaryTap: + ( + context, + message, { + required details, + required index, + }) => showContextMenu( + context: context, + globalPosition: details.globalPosition, + onTap: () => replyToMessage.value = message, ), - ) - : SizedBox.shrink(), - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatTextMessage( - topWidget: TopWidget(message, headers: headers), - message: message.copyWith( - text: message.text.replaceAllMapped( - urlRegex, - (match) => - "[${match.group(0)}](${match.group(0)})", + onMessageLongPress: + ( + context, + message, { + required details, + required index, + }) => showContextMenu( + context: context, + globalPosition: details.globalPosition, + onTap: () => replyToMessage.value = message, ), + builders: Builders( + chatAnimatedListBuilder: (context, itemBuilder) { + return ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: ref + .watch(controllerProvider.notifier) + .loadOlder, + ); + }, + composerBuilder: (_) => ChatBox( + replyToMessage: replyToMessage.value, + onDismiss: () => replyToMessage.value = null, + headers: room.roomData.client.headers, ), - 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( + textMessageBuilder: + ( + context, message, - message.copyWith( - linkPreviewData: linkPreviewData, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatTextMessage( + topWidget: TopWidget( + message, + headers: room.roomData.client.headers, + groupStatus: groupStatus, + ), + 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( + 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(), ), - imageMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatImageMessage( - topWidget: TopWidget(message, headers: headers), - message: message, - index: index, - headers: headers, - ), - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => InkWell( - onTap: () => showAboutDialog( - context: context, - ), // TODO: Download - child: FlyerChatFileMessage( - topWidget: TopWidget(message, headers: headers), - message: message, - index: index, - ), - ), - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - ), - onMessageSend: (message) { - ref - .watch(controllerProvider.notifier) - .send(message, replyTo: replyToMessage.value); - replyToMessage.value = null; - }, - resolveUser: ref - .watch(controllerProvider.notifier) - .resolveUser, - chatController: controller, - ), + onMessageSend: (message) { + ref + .watch(controllerProvider.notifier) + .send(message, replyTo: replyToMessage.value); + replyToMessage.value = null; + }, + resolveUser: ref + .watch(controllerProvider.notifier) + .resolveUser, + chatController: controller, + ), + ), ), + + if (memberListOpened.value == true) MemberList(room.roomData), + ], + ), ); }, ); diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index e69e173..4f98092 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -4,7 +4,7 @@ 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/room_avatar.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; class Sidebar extends HookConsumerWidget { const Sidebar({super.key}); @@ -38,7 +38,12 @@ class Sidebar extends HookConsumerWidget { destinations: spaces .map( (space) => NavigationRailDestination( - icon: RoomAvatar(space.avatar, space.title), + icon: AvatarOrHash( + space.avatar, + fallback: space.icon, + space.title, + headers: space.client.headers, + ), label: Text(space.title), padding: EdgeInsets.only(top: 4), ), @@ -58,7 +63,12 @@ class Sidebar extends HookConsumerWidget { appBar: AppBar( title: Row( children: [ - RoomAvatar(space.avatar, space.title), + AvatarOrHash( + space.avatar, + fallback: space.icon, + space.title, + headers: space.client.headers, + ), SizedBox(width: 12), Expanded( child: Text( @@ -80,12 +90,13 @@ class Sidebar extends HookConsumerWidget { destinations: space.children .map( (room) => NavigationRailDestination( - icon: RoomAvatar( + icon: AvatarOrHash( room.avatar, room.title, fallback: selectedSpace.value == 1 ? null : Icon(Icons.numbers), + headers: space.client.headers, ), label: Text(room.title), ), diff --git a/lib/widgets/top_widget.dart b/lib/widgets/top_widget.dart index 623b2aa..830eaa7 100644 --- a/lib/widgets/top_widget.dart +++ b/lib/widgets/top_widget.dart @@ -10,7 +10,13 @@ import "package:nexus/helpers/extension_helper.dart"; class TopWidget extends ConsumerWidget { final Message message; final Map headers; - const TopWidget(this.message, {required this.headers, super.key}); + final MessageGroupStatus? groupStatus; + const TopWidget( + this.message, { + required this.headers, + required this.groupStatus, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) => Column( @@ -32,16 +38,14 @@ class TopWidget extends ConsumerWidget { (message as TextMessage).text.length - 20, replyMessage.text.length, ), - 40, + 5, ), replyMessage.text.length, ), ) : replyMessage.text; return InkWell( - onTap: () => showAboutDialog( - context: context, - ), // TODO: Scroll to message + onTap: () => showAboutDialog(context: context), child: Container( decoration: BoxDecoration( border: Border( @@ -62,11 +66,14 @@ class TopWidget extends ConsumerWidget { headers: headers, size: 16, ), - Text( - replyMessage.metadata?["displayName"] ?? - replyMessage.authorId, - style: Theme.of(context).textTheme.labelMedium - ?.copyWith(fontWeight: FontWeight.bold), + Flexible( + child: Text( + replyMessage.metadata?["displayName"] ?? + replyMessage.authorId, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), ), Flexible( child: Text( @@ -85,23 +92,27 @@ class TopWidget extends ConsumerWidget { ), SizedBox(height: 12), ], - 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.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ], + 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), ], );