Flutter chat with multiple users

This document presents an advanced chat example that allows multi-user chat. It describes the example_advanced_chat.dart available in the ./lib/ folder.

It assumes that you have followed the Minimalist Flutter Chat tutorial, and will only describes function that uses new parts of the Discret API.

As a starting point:

rm ./lib/main.dart
cp ./lib/example_advanced_chat.dart ./lib/main.dart
flutter build linux

Once compiled, do to folder do to the folder indicated at the end of the compilation. On a Linux device, the folder is the following:

cd build/linux/x64/release/bundle/

If you copy the program on another local device and re-create one of the account, you should be able to discuss between the two devices.

With three different accounts, the screen should look like that:

Preparing the API

The Api initialization does not change much compared to the previous example.

The folder to store data is changed to support Android devices (and probably IOS).

18//...
19void main() async {
20 WidgetsFlutterBinding.ensureInitialized();
21 final path = await _localPath;
22 String dataPath = '$path/.discret_data';

We also set the log level to info. Log levels can be changed anytime with the values:

27 await Discret().setLogLevel("info");

Architecture

To manage several discussion groups, the ChatState provides a list of ChatRoom and all the functions required to

class ChatState extends ChangeNotifier {
  final List<ChatRoom> chatRooms = [];
//..
}

The ChatRoom the fields and function required to send and receive messages.

class ChatRoom {
  final List<ChatEntry> chat = [];
  String roomId;
  String name;
  int mdate;
}

Initialization

The ChatState initialization is more complex that the previous one.

During the ChatSate creation, a log listener is created. Those logs are primarily used to send error message that are not directly related to an API call.

The code only push the logs in the Flutter log Flutter log for debugging purposes.

50 final StreamSubscription<LogMsg> loglistener =
51 Discret().logStream().listen((event) async {
52 var message = event.data;
53 var level = event.level;
54 logger.log('$level: $message');
55 });

--

Line 68 retrieve the user's verifyingKey. It represents the public identity of this user.

Data created by this user are signed with the associated signature key and other peers will verify the signature during data synchronization.

    msg = await Discret().verifyingKey();
    myVerifyingKey = msg.data;

The event stream is also more complex and manage more events.

eventlistener = Discret().eventStream().listen((event) async {
    switch (event.type) {
      case EventType.dataChanged:
        {
          DataModification modif = event.data as DataModification;
          modif.rooms?.forEach((key, value) {
            if (chatMap.containsKey(key)) {
              ChatRoom chatRoom = chatMap[key]!;
              chatRoom.refresh();
            }
          });
          chatRooms.sort((a, b) => b.mdate.compareTo(a.mdate));
          notifyListeners();
        }
      case EventType.roomModified:
        {
          String roomId = event.data as String;
          await loadRoom(roomId);
          notifyListeners();
        }
      case EventType.peerConnected:
        {
          PeerConnection peer = event.data as PeerConnection;
          var key = peer.verifyingKey;
          logger.log('Peer connected: $key');
        }
      case EventType.peerDisconnected:
        {
          PeerConnection peer = event.data as PeerConnection;
          var key = peer.verifyingKey;
          logger.log('Peer disconnected: $key');
        }
      default:
    }
  }, onDone: () {}, onError: (error) {});

The EventType.dataChanged event have take into account the different discussion groups and will only refresh the Rooms that have new data.

A new event is managed: EventType.roomModified. This event is triggered by the invitation functions when creating new invites and accepting invites. It detects that Rooms have been created or modified.

The events EventType.peerConnected and EventType.peerDisconnected are triggered when a peer connect or disconnect. It is not directly used by the code but put in the flutter log for debugging purposes.

Creating an Account

The login function does not change much, but the account creation is more complex because it has to manage the new Display Name field.

This field is used to associate a name to your public identity (your verifying_key). Your identity and name will be sent to other peers when accepting invitations.

This association is done in the sys.Peer that contains all known peers, including you.

128 Future<ResultMsg> createAccount(
129 String userName, String password, String displayName) async {
130 ResultMsg msg = await Discret().login(userName, password, true);
131 if (msg.successful) {
132 await initialise();
133 String query = """query{
134 sys.Peer(verifying_key=\$verifyingKey){
135 id
136 }
137 }""";
138
139 ResultMsg res =
140 await Discret().query(query, {"verifyingKey": myVerifyingKey});
141 if (!res.successful) {
142 logger.log(res.error);
143 return res;
144 }
145
146 final json = jsonDecode(res.data) as Map<String, dynamic>;
147 final msgArray = json["sys.Peer"] as List<dynamic>;
148 var result = msgArray[0] as Map<String, dynamic>;
149 var id = result["id"];
150 String mutate = """mutate {
151 sys.Peer{
152 id:\$id
153 name:\$name
154 }
155 }""";
156 res = await Discret().mutate(mutate, {"id": id, "name": displayName});
157 if (!res.successful) {
158 logger.log(res.error);
159 return res;
160 }
161 return res;
162 }
163 return msg;
164 }

The first query line 133 retrieve the unique identifier id of the tuple that stores your identity.

Every tuple in the Discret database have a unique id generated during the insertion.

String query = """query{
  sys.Peer(verifying_key=\$verifyingKey){
    id
  }
}""";

The second query line 150 updates the name field with the Display Name value. Every mutation query that provides an id is an update query. If the id is not found, an error will be returned.

String mutate = """mutate {
  sys.Peer{
    id:\$id
    name:\$name
  }
}""";

Creating an invite

Every new invites needs to create a new Room to limit access to to discussion group to you and the invited peer.

273 Future<String> createInvite() async {
274 String query = """mutate {
275 sys.Room{
276 admin: [{
277 verif_key:\$verifyingKey
278 }]
279 authorisations:[{
280 name:"chat"
281 rights:[{
282 entity:"chat.Message"
283 mutate_self:true
284 mutate_all:false
285 }]
286 users:[{
287 verif_key:\$verifyingKey
288 }]
289 }]
290 }
291 }""";
292
293 ResultMsg res =
294 await Discret().mutate(query, {"verifyingKey": myVerifyingKey});
295
296 if (!res.successful) {
297 logger.log(res.error);
298 return "";
299 }
300
301 final json = jsonDecode(res.data) as Map<String, dynamic>;
302 final msgArray = json["sys.Room"] as Map<String, dynamic>;
303 String roomId = msgArray["id"];
304
305 final auths = msgArray["authorisations"] as List<dynamic>;
306 final authorisation = auths[0] as Map<String, dynamic>;
307 final authId = authorisation["id"];
308
309 ResultMsg invite = await Discret().invite(roomId, authId);
310 if (!res.successful) {
311 logger.log(res.error);
312 return "";
313 }
314 return invite.data;
315 }

The query ligne 274 creates a new room for the invited peer.

String query = """mutate {
  sys.Room{
    admin: [{
      verif_key:\$verifyingKey
    }]
    authorisations:[{
      name:"chat"
      rights:[{
        entity:"chat.Message"
        mutate_self:true
        mutate_all:false
      }]
      users:[{
          verif_key:\$verifyingKey
      }]
    }]
  }
}""";

When creating the Room, the invited peer identity (its verifying_key) is not yet known, so you just insert your identity.

You can notice that your identity is used twice:

The rights field, indicate that only tuples of the chat.Message entity are allowed in this room. Trying to insert other things will return an error.


The API to create an invite requires:

Those identifiers are retrieved by parsing the result of the query (lines 293 to 307) that contains every ids generated during insertion.

Once the invite accepted, the new peer identity will be inserted in the user field of the provided autorisation.


Calling the API will retur a large invitation String that you will need to manually transmit to the invited peer

 ResultMsg invite = await Discret().invite(roomId, authId);
  //..
 return invite.data;

Accepting an invitation

Accepting an invitation is just an API call with the invitation string.

An invitation can only be used once.

320 Future<void> acceptInvite(String invite) async {
321 ResultMsg msg = await Discret().acceptInvite(invite);
322 if (!msg.successful) {
323 logger.log(msg.error);
324 }
325 }