Skip to content

Commit

Permalink
Merge pull request #2388 from gtalha07/chat-ng-list
Browse files Browse the repository at this point in the history
[Chat-NG]: Room messages UI implementation
  • Loading branch information
gtalha07 authored Dec 6, 2024
2 parents 1a3c11c + a21626c commit 3849fc0
Show file tree
Hide file tree
Showing 15 changed files with 1,254 additions and 82 deletions.
6 changes: 6 additions & 0 deletions .changes/2388-chat-ng-list-UI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- [Labs] Chat-NG:
- supports new UI message bubbles with initial message types support .i.e. text, image, video.
- messages list now also supports initial added state events .i.e. member, redacted, encrypted.
- pagination to load more messages upon scrolling.
- Initial scroll to new messages on entering room.
- Updated test coverage.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import 'package:logging/logging.dart';
import 'package:riverpod/riverpod.dart';

final _log = Logger('a3::chat::message_provider');
const _supportedTypes = [
'm.room.member',
'm.room.message',
'm.room.redaction',
'm.room.encrypted',
];

typedef RoomMsgId = (String roomId, String uniqueId);
typedef MentionQuery = (String, MentionType);
Expand All @@ -28,8 +34,6 @@ final chatRoomMessageProvider =

final showHiddenMessages = StateProvider((ref) => false);

const _supportedTypes = ['m.room.message'];

final animatedListChatMessagesProvider =
StateProvider.family<GlobalKey<AnimatedListState>, String>(
(ref, roomId) => ref.watch(chatStateProvider(roomId).notifier).animatedList,
Expand All @@ -55,6 +59,33 @@ final renderableChatMessagesProvider =
}).toList();
});

// Provider to check if we should show avatar by comparing with the next message
final isNextMessageGroupProvider = Provider.family<bool, RoomMsgId>(
(ref, roomMsgId) {
final roomId = roomMsgId.$1;
final eventId = roomMsgId.$2;
final messages = ref.watch(renderableChatMessagesProvider(roomId));
final currentIndex = messages.indexOf(eventId);

// Always show avatar for the first message (last in the list), so not affecting group state
if (currentIndex == messages.length - 1) return false;

// Get current and next message
final currentMsg = ref.watch(chatRoomMessageProvider(roomMsgId));
final nextMsg = ref.watch(
chatRoomMessageProvider((roomId, messages[currentIndex + 1])),
);

if (currentMsg == null || nextMsg == null) return true;

final currentSender = currentMsg.eventItem()?.sender();
final nextSender = nextMsg.eventItem()?.sender();

// is next message in group from same sender
return currentSender == nextSender;
},
);

/// Provider to fetch user mentions
final userMentionSuggestionsProvider =
StateProvider.family<Map<String, String>?, String>((ref, roomId) {
Expand Down
121 changes: 121 additions & 0 deletions app/lib/features/chat_ng/widgets/chat_bubble.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import 'package:acter/common/themes/acter_theme.dart';
import 'package:flutter/material.dart';
import 'package:acter/common/extensions/options.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

class ChatBubble extends StatelessWidget {
final Widget child;
final int? messageWidth;
final bool wasEdited;
final bool nextMessageGroup;
final BoxDecoration decoration;
final CrossAxisAlignment bubbleAlignment;

// default private constructor
const ChatBubble._inner({
super.key,
required this.child,
required this.wasEdited,
required this.bubbleAlignment,
required this.decoration,
this.messageWidth,
this.nextMessageGroup = false,
});

// factory bubble constructor
factory ChatBubble({
required Widget child,
required BuildContext context,
int? messageWidth,
bool wasEdited = false,
bool nextMessageGroup = false,
}) {
final theme = Theme.of(context);
return ChatBubble._inner(
wasEdited: wasEdited,
messageWidth: messageWidth,
nextMessageGroup: nextMessageGroup,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(nextMessageGroup ? 16 : 4),
bottomRight: Radius.circular(16),
),
),
bubbleAlignment: CrossAxisAlignment.start,
child: child,
);
}

// for user's own messages
factory ChatBubble.user({
Key? key,
required BuildContext context,
required Widget child,
int? messageWidth,
bool wasEdited = false,
bool nextMessageGroup = false,
}) {
final theme = Theme.of(context);
return ChatBubble._inner(
key: key,
messageWidth: messageWidth,
wasEdited: wasEdited,
nextMessageGroup: nextMessageGroup,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(nextMessageGroup ? 16 : 4),
),
),
bubbleAlignment: CrossAxisAlignment.end,
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.onPrimary),
child: child,
),
);
}

@override
Widget build(BuildContext context) {
final chatTheme = Theme.of(context).chatTheme;
final size = MediaQuery.sizeOf(context);
final msgWidth = messageWidth.map((w) => w.toDouble());

return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: bubbleAlignment,
children: [
Container(
constraints: BoxConstraints(
maxWidth: msgWidth ?? size.width,
),
width: msgWidth,
decoration: decoration,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: child,
),
),
Visibility(
visible: wasEdited,
child: Text(
L10n.of(context).edited,
style: chatTheme.emptyChatPlaceholderTextStyle
.copyWith(fontSize: 12),
),
),
],
),
);
}
}
136 changes: 120 additions & 16 deletions app/lib/features/chat_ng/widgets/chat_messages.dart
Original file line number Diff line number Diff line change
@@ -1,27 +1,131 @@
import 'package:acter/features/chat/widgets/rooms_list.dart';
import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
import 'package:acter/features/chat_ng/widgets/events/chat_event_widget.dart';
import 'package:flutter/widgets.dart';
import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatMessages extends ConsumerWidget {
class ChatMessages extends ConsumerStatefulWidget {
final String roomId;
const ChatMessages({super.key, required this.roomId});

@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref
.watch(chatStateProvider(roomId).select((value) => value.messageList));
final animatedListKey = ref.watch(animatedListChatMessagesProvider(roomId));

return AnimatedList(
initialItemCount: messages.length,
reverse: false,
key: animatedListKey,
itemBuilder: (_, index, animation) => ChatEventWidget(
roomId: roomId,
eventId: messages[index],
animation: animation,
ConsumerState<ChatMessages> createState() => _ChatMessagesConsumerState();
}

class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
final ScrollController _scrollController =
ScrollController(keepScrollOffset: true);

bool get isLoading => ref.watch(
chatStateProvider(widget.roomId).select((v) => v.loading.isLoading),
);

@override
void initState() {
super.initState();
// for first time messages load, should scroll at the latest (bottom)
ref.listenManual(
chatStateProvider(widget.roomId)
.select((value) => value.messageList.length), (_, __) {
if (_scrollController.hasClients &&
_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 150) {
WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd());
}
});
_scrollController.addListener(onScroll);
}

@override
void dispose() {
_scrollController.dispose();
super.dispose();
}

Future<void> onScroll() async {
if (isLoading) return;

// Check if we're near the top of the list
if (_scrollController.position.pixels <=
_scrollController.position.minScrollExtent) {
if (isLoading) return;

// Get the notifier to load more messages
final notifier = ref.read(chatStateProvider(widget.roomId).notifier);
await notifier.loadMore();
}
}

void scrollToEnd() {
if (!mounted || !_scrollController.hasClients) return;

_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}

@override
Widget build(BuildContext context) {
final messages = ref.watch(
chatStateProvider(widget.roomId).select((value) => value.messageList),
);

return PageStorage(
bucket: bucketGlobal,
child: Column(
children: [
Expanded(
child: Stack(
children: [
_buildMessagesList(messages),
_buildScrollIndicator(),
],
),
),
],
),
);
}

Widget _buildMessagesList(
List<String> messages,
) =>
KeyedSubtree(
key: PageStorageKey('chat_list_${widget.roomId}'),
child: AnimatedList(
initialItemCount: messages.length,
key: ref.watch(animatedListChatMessagesProvider(widget.roomId)),
controller: _scrollController,
reverse: false,
padding: const EdgeInsets.only(
top: 40,
),
itemBuilder: (_, index, animation) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: ChatEvent(
roomId: widget.roomId,
eventId: messages[index],
),
),
),
);

Widget _buildScrollIndicator() => Positioned(
top: 12,
left: 0,
right: 0,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: isLoading ? 14 : 0,
width: isLoading ? 14 : 0,
child: Center(
child: isLoading ? const CircularProgressIndicator() : null,
),
),
),
);
}
Loading

0 comments on commit 3849fc0

Please sign in to comment.