Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import '../../shared/ai_test_op.dart';
import '../../shared/util.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('chat page:', () {
testWidgets('send messages', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();

// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);

final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '457037009907617793';
final aiUser = User(id: aiUserId, lastName: 'AI');

// focus on the chat page
final int messageId = 1;

// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);

// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);

await tester.wait(100000);
});
});
}
18 changes: 18 additions & 0 deletions frontend/appflowy_flutter/integration_test/shared/ai_test_op.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';

import '../../test/util.dart';
import 'util.dart';

extension AppFlowyAITest on WidgetTester {
Expand Down Expand Up @@ -38,4 +43,17 @@ extension AppFlowyAITest on WidgetTester {
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}

Future<void> sendUserMessage(Message message) async {
final chatBloc = element(find.byType(ChatContentPage)).read<ChatBloc>();
// using received message to simulate the user message
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}

Future<void> receiveAIMessage(Message message) async {
final chatBloc = element(find.byType(ChatContentPage)).read<ChatBloc>();
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,8 @@ extension ViewLayoutPBTest on ViewLayoutPB {
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
case ViewLayoutPB.Chat:
return LocaleKeys.chat_newChat.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await event.when(
// Loading messages
didLoadLatestMessages: (List<Message> messages) async {
Log.debug(
"[ChatBloc] did load latest messages: ${messages.length}",
);

for (final message in messages) {
Log.debug("[ChatBloc] insert message: ${message.toJson()}");
await chatController.insert(message, index: 0);
}

Expand Down Expand Up @@ -160,6 +165,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatController.insert(message);
},
receiveMessage: (Message message) {
Log.debug("[ChatBloc] receive message: ${message.toJson()}");
final oldMessage = chatController.messages
.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// ignore_for_file: implementation_imports

import 'dart:async';
import 'dart:math';

import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -72,13 +72,20 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
duration: const Duration(milliseconds: 200),
);

int initialScrollIndex = 0;
double initialAlignment = 1.0;
List<Message> messages = [];

@override
void initState() {
super.initState();

// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
setState(() {
messages = _chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
assert(
Expand All @@ -89,6 +96,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when inserting a message.',
);

_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
Expand All @@ -101,6 +109,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when removing a message.',
);

_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
Expand All @@ -124,6 +133,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
}
});

messages = _chatController.messages;

_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
Expand All @@ -141,6 +152,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});

// A trick to avoid the first message being scrolled to the top
}

@override
Expand All @@ -157,10 +170,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;

// A trick to avoid the first message being scrolled to the top
int initialScrollIndex = _chatController.messages.length;
double initialAlignment = 1.0;
if (_chatController.messages.length <= 2) {
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
initialScrollIndex = 0;
initialAlignment = 0.0;
}
Expand All @@ -177,13 +189,18 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
physics: ClampingScrollPhysics(),
shrinkWrap: true,
// the extra item is a vertical padding.
itemCount: _chatController.messages.length + 1,
itemCount: messages.length + 1,
itemBuilder: (context, index) {
if (index == _chatController.messages.length) {
return VSpace(height - 360);
if (index < 0 || index > messages.length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Review index bounds check in list builder.

Confirm that the “index > messages.length” check covers all edge cases, and consider adding an assert to catch programming errors early.

Suggested implementation:

          itemBuilder: (context, index) {
            assert(index >= 0 && index <= messages.length, '[chat animation list] index out of range: $index');

            if (index < 0 || index > messages.length) {
              Log.error('[chat animation list] index out of range: $index');
              return const SizedBox.shrink();
            }

Ensure that the assert line is only used in development builds and review the index out of bounds condition to confirm it works correctly with itemCount = messages.length + 1.

Log.error('[chat animation list] index out of range: $index');
return const SizedBox.shrink();
}

final message = _chatController.messages[index];
if (index == messages.length) {
return VSpace(height - 400);
}

final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
Expand Down Expand Up @@ -211,15 +228,19 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return child;
}

void _scrollLastMessageToTop(Message data) {
Future<void> _scrollLastUserMessageToTop() async {
final user = Provider.of<User>(context, listen: false);
final lastUserMessageIndex = _chatController.messages.lastIndexWhere(
final lastUserMessageIndex = messages.lastIndexWhere(
(message) => message.author.id == user.id,
);

if (lastUserMessageIndex == -1) {
return;
}

if (_lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
itemScrollController.scrollTo(
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
Expand All @@ -237,7 +258,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
await _scrollToBottomController.reverse();

await itemScrollController.scrollTo(
index: _chatController.messages.length + 1,
index: messages.length + 1,
alignment: 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
Expand All @@ -260,8 +281,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}

if (maxItem.index > _chatController.messages.length - 1 ||
(maxItem.index == _chatController.messages.length - 1 &&
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.reverse();
Expand Down Expand Up @@ -292,22 +313,14 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
}

void _onInserted(final int position, final Message data) {
Future<void> _onInserted(final int position, final Message data) async {
// scroll the last user message to the top if it's the last message
if (position == _oldList.length) {
_scrollLastMessageToTop(data);
await _scrollLastUserMessageToTop();
}
}

void _onRemoved(final int position, final Message data) {}

void _onChanged(int position, Message oldData, Message newData) {}

void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,33 @@ class ChatAnimationListWidget extends StatefulWidget {
}

class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
bool hasMessage = false;

@override
void initState() {
super.initState();

final bloc = context.read<ChatBloc>();
if (bloc.chatController.messages.isNotEmpty) {
hasMessage = true;
}

bloc.chatController.operationsStream.listen((operation) {
final newHasMessage = bloc.chatController.messages.isNotEmpty;

if (hasMessage != newHasMessage) {
setState(() {
hasMessage = newHasMessage;
});
}
});
}

@override
Widget build(BuildContext context) {
final bloc = context.read<ChatBloc>();

// this logic is quite weird, why don't we just get the message from the state?
if (bloc.chatController.messages.isEmpty) {
if (!hasMessage) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) {
Expand Down Expand Up @@ -70,6 +91,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
Expand All @@ -80,6 +104,9 @@ class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
}

Widget _buildBody(BuildContext context) {
final bool enableAnimation = true;
return Expanded(
child: Align(
alignment: Alignment.topCenter,
Expand All @@ -70,6 +71,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
message: message,
userProfile: userProfile,
view: view,
enableAnimation: enableAnimation,
),
chatMessageBuilder: (
context,
Expand Down Expand Up @@ -100,6 +102,7 @@ class LoadChatMessageStatusReady extends StatelessWidget {
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: !enableAnimation,
),
),
),
Expand Down
Loading