If you want your Flutter chat app to stand out against the rest, consider adding additional features to make your app more fun and engaging. In our previous blog, we showed you How to Create Polls & Surveys in your Flutter Chat App using QuickBlox. In this blog, we’ll show you how to build another cool feature on top of the QuickBlox SDK. Read on to learn how to create facebook-like message reactions that will delight your users.
Find out more about: How to Save and Share Files in your Flutter Chat App
To set up the template application for building the react-to-message functionality, we have taken code from the official Flutter chat sample application of Quickblox. The Quickblox sample chat app makes it really easy to build chat fast.
Learn more about: Introducing the QuickBlox Flutter Code Sample
Check out our template application code.
This application has three screens: Splash, Login, and Chat. For this article, the _dialogId
on the Chat screen has been hard-coded so that users are taken directly to the relevant Group chat.
To create a Flutter app using the QuickBlox SDK, you will new a QuickBlox developer account.
Create a new account, or sign-in to your existing account. You can also use your Google or GitHub accounts to sign in.
After acquiring the application credentials, paste them into the main.dart
file that is included in the template application. If the above steps have been followed correctly, the application can now be run with the following commands:
flutter packages get flutter run
Note: We recommend that you add your credentials to the keys.env
file and include it in the .gitignore
for security purposes.
Congratulations! Your basic chat application is now up and running!
To start with, let’s take a moment to understand the data models offered by QuickBlox. After that, we can create our models on top of them to extend functionalities and create reactions.
QBMessageWrapper
is a wrapper built around the default data object, QBMessage
, provided by Quickblox. This wrapper contains additional fields such as senderName, and date that are useful when displaying the message data on a chat screen.
QBMessage
is a default data object that consists of the message identifier(id), text message(body), and extra metadata (properties) about a message.
QBMessage
is great for rendering static text messages, locations, and web links, but it cannot be used to add real-time updating react-to-message features. To accomplish this, Quickblox provides Custom Objects, which is a custom key-value database schema that can be updated in real-time, making it an ideal fit for the react-to-message feature.
Learn more about: What are Custom Objects and how they can Benefit your Application
Next, let’s go to our Quickblox Dashboard, select “Custom,” click “Add,” and select “Add New Class.” We will then create a custom schema class called “Reaction” as shown below.
Once created, open the “Edit Permissions” option, adjust the permission level, and select the appropriate checkboxes as shown below:
Note: If users do not have open permissions, they will not be able to modify the Reaction values from the app.
We will create two data models: MessageReactProperties
and MessageActionReact
.
MessageReactProperties
will contain a map of user ids and reaction ids that represent the reactions users have to a message. It will also have a toJson
method which will return a map of the class needed to create the Reaction custom object.MessageActionReact
will include messageReactId
, reacts
, currentUserId
, and chosenReactionId
. This will assist us in updating our custom object. A getter, updatedReacts
, will recalculate the reactions with the user-chosen option and return the updated value.Note: The values of the map are encoded as strings using jsonEncode, as Quickblox custom objects do not support the map data type.
import 'dart:convert'; class MessageReactProperties { const MessageReactProperties({ required this.reacts, }); final Mapreacts; Map toJson() { return { "reacts": jsonEncode({}), }; } factory MessageReactProperties.fromData() { return const MessageReactProperties( reacts: {}, ); } } class MessageActionReact { const MessageActionReact({ required this.messageReactId, required this.reacts, required this.currentUserId, required this.chosenReactionId, }); final String messageReactId; final Map reacts; final String chosenReactionId; final String currentUserId; Map get updatedReacts { reacts[currentUserId] = chosenReactionId; return {"reacts": jsonEncode(reacts)}; } }
We need a model to handle incoming data, in addition to the models we use to parse and send data.
To do this, let’s create a ReactionMessage
class that extends QBMessageWrapper
and will contain the necessary properties to handle the reaction-related data.
import 'dart:convert'; import 'package:quickblox_polls_feature/models/message_wrapper.dart'; import 'package:quickblox_sdk/models/qb_custom_object.dart'; import 'package:quickblox_sdk/models/qb_message.dart'; class ReactionMessage extends QBMessageWrapper { ReactionMessage( super.senderName, super.message, super.currentUserId, { required this.messageReactId, required this.reacts, }); final String messageReactId; final Mapreacts; factory ReactionMessage.fromCustomObject(String senderName, QBMessage message, int currentUserId, QBCustomObject object) { return ReactionMessage( senderName, message, currentUserId, messageReactId: message.properties!['messageReactId']!, reacts: Map .from( jsonDecode(object.fields!['reacts'] as String), ), ); } ReactionMessage copyWith({Map ? reacts}) { return ReactionMessage( senderName!, qbMessage, currentUserId, messageReactId: messageReactId, reacts: reacts ?? this.reacts, ); } } const REACTION_ID_MAP = { "#001": "assets/images/love.png", "#002": "assets/images/laugh.png", "#003": "assets/images/sad.png", "#004": "assets/images/angry.png", "#005": "assets/images/wow.png", };
Now that we have finished setting up the models, let’s move on to the logic portion of our project. You may have already noticed that we have a constant map that stores the reaction ids and maps them to the image path of the reactions. This will be useful when we get the ids and the corresponding reaction.
In the previous section, we created data models to support reactions. Now, we will write the logic to utilize these models.
Let’s map out the steps necessary to incorporate reactions on text messages.
ReactionMessage
.Let’s go to _sendTextMessage
in chat_screen_bloc
for our first point. This method is invoked when we want to transmit a text message; we will adjust it to accept extra properties
Futurec _sendTextMessage( String text, { required MessageReactProperties reactProperties, }) async { if (text.length > TEXT_MESSAGE_MAX_SIZE) { text = text.substring(0, TEXT_MESSAGE_MAX_SIZE); } await _chatRepository.sendMessage( _dialogId, text, reactProperties: reactProperties, ); }
We have added the named parameter reactProperties
, so let’s incorporate it into the sendMessage
function in chat_repository
and alter the method according to the second point outlined in the steps above.
Future<void> sendMessage( String? dialogId, String messageBody, { Map? properties, required MessageReactProperties reactProperties, }) async { if (dialogId == null) { throw RepositoryException(_parameterIsNullException, affectedParams: ["dialogId"]); } //Create custom object to hold reactions for the message final List reactObject = await QB.data.create( className: 'Reaction', fields: reactProperties.toJson(), ); //Get the id of custom object final messageReactId = reactObject.first!.id!; //Add the id to message properties properties ??= {}; properties['messageReactId'] = messageReactId; //Send message await QB.chat.sendMessage( dialogId, body: messageBody, saveToHistory: true, markable: true, properties: properties, ); }
We will create a custom object to hold reactions and pass its ID as a property to the message. After that, we’ll send a reaction message and update the custom object when a user reacts.
To do this, we’ll create a ReactMessageEvent
in the chat_screen_events
and push it from the UI to the BLoC, which will trigger a repository call to communicate with Quickblox servers.
class ReactMessageEvent extends ChatScreenEvents { final MessageActionReact data; ReactMessageEvent(this.data); }
Now in the chat_screen_bloc
, let’s check for ReactMessageEvent
:
if (receivedEvent is ReactMessageEvent) { try { await Future.delayed(const Duration(milliseconds: 300), () async { await _sendReactMessage( data: receivedEvent.data, ); }); } on PlatformException catch (e) { states?.add( SendMessageErrorState(makeErrorMessage(e), 'Can\'t react to message'), ); } on RepositoryException catch (e) { states ?.add(SendMessageErrorState(e.message, 'Can\'t react to message')); } } Future<void> _sendReactMessage({required MessageActionReact data}) async { await _chatRepository.sendReactMessage( _dialogId, data: data, ); }
In the chat_repository
, create the sendReactMessage
method and update the reactions in the custom object accordingly.
Future<void> sendReactMessage( String? dialogId, { required MessageActionReact data, }) async { if (dialogId == null) { throw RepositoryException(_parameterIsNullException, affectedParams: ["dialogId"]); } await QB.data.update( "Reaction", id: data.messageReactId, fields: data.updatedReacts, ); await QB.chat.sendMessage( dialogId, markable: true, properties: { "action": "messageActionReact", "messageReactId": data.messageReactId }, ); }
We are not saving the reaction message to history. This is because the sole purpose of sendMessage
is to inform the current clients that their reactions to a message have changed.
Note: saveToHistory
should be used carefully, as pagination multiple times for groups of over 100 people can lead to a collection of superfluous React messages.
To learn more about parameters such as markable
and saveToHistory
, consult the Quickblox official documentation.
On the receiver’s end, we need to check if a reaction message has been received and update the reactions on the text message. To do this, we can write the following steps:
In the chat_screen_bloc
file, we have the HashSet<QBMessageWrapper
> _wrappedMessageSet
, which stores all messages sorted by time.
We also have a method _wrapMessages()
, which is called every time we receive new messages and is responsible for wrapping the QBMesssage
(s) in the List<QBMessageWrappers
>. We will now update this method to also handle reactions.
messageReactId
. Fetch the corresponding reaction custom object for that id, add the reactions to the message, and convert it into a ReactionMessage
object.action
as messageActionReact
, get the custom object with the id, get the reactions for the custom object, find the corresponding text message with the id, update the reactions, remove it from the list, and add the updated ReactionMessage
to the list.Futuremessages) async { List
wrappedMessages = []; for (QBMessage? message in messages) { if (message == null) { break; } QBUser? sender = _getParticipantById(message.senderId); if (sender == null && message.senderId != null) { List users = await _usersRepository.getUsersByIds([message.senderId!]); if (users.isNotEmpty) { sender = users[0]; _saveParticipants(users); } } String senderName = sender?.fullName ?? sender?.login ?? "DELETED User"; if (message.properties?['action'] == 'pollActionVote') { //SOME CODE HERE } else if (message.properties?['action'] == 'pollActionCreate') { //SOME CODE HERE //OUR CODE HERE } else if (message.properties?['action'] == 'messageActionReact') { //Get the ID out of react message. final id = message.properties!['messageReactId']!; try { //Get the custom object. final reactObject = await _chatRepository .getCustomObject(ids: [id], className: 'Reaction'); //Get updated reactions and update the message. if (reactObject != null) { final reacts = Map .from( jsonDecode(reactObject.first!.fields!['reacts'] as String), ); final reactMessage = _wrappedMessageSet.firstWhere((element) => element is ReactionMessage && element.messageReactId == id) as ReactionMessage; _wrappedMessageSet.removeWhere((element) => element is ReactionMessage && element.messageReactId == id); wrappedMessages.add( reactMessage.copyWith(reacts: reacts), ); } } catch (e) { wrappedMessages .add(QBMessageWrapper(senderName, message, _localUserId!)); } } else { if (message.properties?['messageReactId'] != null) { //Get the ID out of react message. final id = message.properties!['messageReactId']!; try { //Get the custom object and add the Reaction Message. final reactObject = await _chatRepository .getCustomObject(ids: [id], className: 'Reaction'); if (reactObject != null) { wrappedMessages.add( ReactionMessage.fromCustomObject( senderName, message, _localUserId!, reactObject.first!, ), ); } } catch (e) { wrappedMessages .add(QBMessageWrapper(senderName, message, _localUserId!)); } } else { wrappedMessages .add(QBMessageWrapper(senderName, message, _localUserId!)); } } } return wrappedMessages; }
The above code has enabled us to create a list of messages with reactions, which can now be displayed in the UI.
Note: We have used a placeholder “SOME CODE HERE” in order to keep the code snippet short and concise.
We have the data models and reaction logic in place, so now let’s focus on creating an attractive UI for our reactions feature. We can start by enabling users to long press on a message to view a list of possible reactions.
Currently, we have a list of PopupMenuItem
options such as Forward
, Delivered to
, etc. However, we want to make PopupMenuItem
display a list of reaction images horizontally instead.
To achieve this, we will create our own widget by extending the base class of PopupMenuItem
, e.g.PopupMenuEntry
.
import 'package:flutter/material.dart'; class PopupMenuWidgetextends PopupMenuEntry { const PopupMenuWidget({ Key? key, required this.height, required this.child, }) : super(key: key); final Widget child; @override final double height; @override PopupMenuWidgetState createState() => PopupMenuWidgetState(); @override bool represents(T? value) => true; } class PopupMenuWidgetState extends State { @override Widget build(BuildContext context) => widget.child; }
In the chat_screen, a GestureDetector is wrapped around the ChatListItem to detect long presses on the message. Now, let’s create a new list of reactions here.
onLongPress: () { RenderBox? overlay = Overlay.of(context) ?.context .findRenderObject() as RenderBox; //OUR CODE List<PopupMenuEntry> messageMenuItems = [ PopupMenuWidget( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (var reaction in REACTION_ID_MAP.entries) _reactWidget( reaction.value, () { //TODO: FIGURE OUT LATER. }, ), ], ), ), //SOME CODE HERE } Widget _reactWidget( String imagePath, VoidCallback onPressed, ) { return InkWell( onTap: onPressed, child: Ink.image( image: AssetImage( imagePath, ), height: 25, width: 25, ), ); }
Note: The assets used for this project can be found in the GitHub repository linked.
We will work on the TODO (listed in the code snippet above) in the next section and create a simple, yet attractive, popup menu with reactions that will look similar to this.
We need to create a minimal UI that will make it easy for both the sender and the receiver to know when a message has been reacted to.
return GestureDetector( child: message is ReactionMessage ? Stack( children: [ ChatListItem( Key( RandomUtil.getRandomString( 10), ), message, _dialogType, ), Positioned( right: message.isIncoming ? null : 20, left: message.isIncoming ? 70 : null, bottom: 0, child: message.reacts.isEmpty ? const SizedBox.shrink() : Container( decoration: BoxDecoration( color: Colors.grey, borderRadius: const BorderRadius .all( Radius.circular(10), ), border: Border.all( width: 3, color: Colors.grey, style: BorderStyle .solid, ), ), child: Row( children: [ for (var reaction in reactionCountMap .entries) Row( children: [ Image.asset( REACTION_ID_MAP[ reaction .key]!, height: 13, width: 13, ), Text( '${reaction.value} ', style: const TextStyle( fontSize: 12.0, ), ), ], ), ], ), ), ), ], ) : ChatListItem( Key( RandomUtil.getRandomString(10), ), message, _dialogType, ), // SOME CODE HERE );
If the message is a reaction message, the reactions will be displayed on the bottom right corner of the message. This will make the message appear as follows:
Although this looks great, we don’t yet know how many people reacted to it with what reaction. Let’s make a map to store the one-of-a-kind reactions and the count of reactions, and adjust the loop to use that map.
//Map to hold unique reactions and their count var reactionCountMap ={}; if (message is ReactionMessage) { var elements = message.reacts.values.toList(); //Populating the map for (var x in elements) { reactionCountMap[x] = !reactionCountMap.containsKey(x) ? (1) : (reactionCountMap[x]! + 1); } } return GestureDetector( child: Stack( children: [ ChatListItem( Key( RandomUtil.getRandomString(10), ), message, _dialogType, ), if (message is ReactionMessage) Positioned( right: message.isIncoming ? null : 20, left: message.isIncoming ? 70 : null, bottom: 0, child: message.reacts.isEmpty ? const SizedBox.shrink() : Container( decoration: BoxDecoration( color: Colors.grey, borderRadius: const BorderRadius .all( Radius.circular(10), ), border: Border.all( width: 3, color: Colors.grey, style: BorderStyle.solid, ), ), //Iterating over map and displaying reactions and counts. child: Row( children: [ for (var reaction in reactionCountMap .entries) Row( children: [ Image.asset( REACTION_ID_MAP[ reaction .key]!, height: 13, width: 13, ), Text( '${reaction.value} ', style: const TextStyle( fontSize: 12.0, ), ), ], ), ], ), ), ), ], ), // SOME CODE HERE );
Doesn’t that look even better? With this, we have finished the UI part.
It’s finally time to integrate our UI with our logic. To make this as straightforward as possible, let’s jot down the steps we need to take.
In the GestureDetector, let’s figure out the TODO:
onLongPress: () { RenderBox? overlay = Overlay.of(context) ?.context .findRenderObject() as RenderBox; //OUR CODE ListmessageMenuItems = [ PopupMenuWidget( height: 20, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ for (var reaction in REACTION_ID_MAP.entries) _reactWidget( reaction.value, () { //Add ReactMessageEvent to the BLoC with chosen reaction. //Pass the old reactions map to preserve them. bloc?.events?.add( ReactMessageEvent( MessageActionReact( chosenReactionId: reaction.key, currentUserId: message .currentUserId .toString(), messageReactId: message .qbMessage .properties![ "messageReactId"]!, reacts: (message as ReactionMessage) .reacts, ), ), ); //Dismiss the popup menu Navigator.of(context).pop(); }, ), ], ), ),
By pushing the reaction event to the BLoC, we are essentially informing the system that a reaction has been made. The rest of the process is handled by our logic part, which detects the event and calls the method from the chat_repository
class to communicate with Quickblox servers.
Demo
If you have followed all the steps correctly, you should now have the react-to-message functionality working. However, if you encounter any errors or missing pieces, you can always compare your code to the full source code available in our repository.
With this, we are finally finished. Well done!
In this article, we focused on building the functionality of the basic reaction, rather than refining the UI/UX and focusing on small details.
Here are some ways it could be improved:
What do you think? Add your ideas to the comments below.