HomeLearnHow-to

Building a Mobile Chat App Using Realm – Integrating Realm into Your App

Published: Feb 01, 2021

  • Realm
  • MongoDB
  • Mobile
  • ...

By Andrew Morgan

Share

This article is a follow-up to Building a Mobile Chat App Using Realm – Data Architecture. Read that post first if you want to understand the Realm data/partitioning architecture and the decisions behind it.

This article targets developers looking to build the Realm mobile database into their mobile apps and use MongoDB Realm Sync. It focuses on how to integrate the Realm-Cocoa SDK into your iOS (SwiftUI) app. Read Building a Mobile Chat App Using Realm – Data Architecture This post will equip you with the knowledge needed to persist and sync your iOS application data using Realm.

RChat is a chat application. Members of a chat room share messages, photos, location, and presence information with each other. The initial version is an iOS (Swift and SwiftUI) app, but we will use the same data model and back end Realm application to build an Android version in the future.

If you're looking to add a chat feature to your mobile app, you can repurpose the article's code and the associated repo. If not, treat it as a case study that explains the reasoning behind the data model and partitioning/syncing decisions taken. You'll likely need to make similar design choices in your apps.

Update: March 2021

Building a Mobile Chat App Using Realm – The New and Easier Way is a follow-on post from this one. It details building the app using the latest SwiftUI features released with Realm-Cocoa 10.6. If you know that you'll only be building apps with SwiftUI (rather than UIKit) then jump straight to that article.

In writing that post, the app was updated to take advantage of those new SwiftUI features, use this snapshot of the app's GitHub repo to view the code described in this article.

#Prerequisites

If you want to build and run the app for yourself, this is what you'll need:

#Walkthrough

The iOS app uses MongoDB Realm Sync to share data between instances of the app (e.g., the messages sent between users). This walkthrough covers both the iOS code and the back end Realm app needed to make it work. Remember that all of the code for the final app is available in the GitHub repo.

#Create a Realm App

From the Atlas UI, select the "Realm" tab. Select the options to indicate that you're creating a new iOS mobile app and then click "Start a New Realm App":

Create a new Realm back end app through the Atlas/Realm UI

Name the app "RChat" and click "Create Realm Application":

Create a new Realm backend app – name it "RChat"

Copy the "App ID." You'll need to use this in your iOS app code:

Take a note of your Realm App ID

#Connect iOS App to Your Realm App

The SwiftUI entry point for the app is RChatApp.swift. This is where you define your link to your Realm application (named app) using the App ID from your new back end Realm app:

1import SwiftUI
2import RealmSwift
3let app = RealmSwift.App(id: "rchat-xxxxx") // TODO: Set the Realm application ID
4@main
5struct RChatApp: SwiftUI.App {
6 @StateObject var state = AppState()
7
8 var body: some Scene {
9 WindowGroup {
10 ContentView()
11 .environmentObject(state)
12 }
13 }
14}

Note that we created an instance of AppState and pass it into our top-level view (ContentView) as an environmentObject. This is a common SwiftUI pattern for making state information available to every view without the need to explicitly pass it down every level of the view hierarchy:

1import SwiftUI
2import RealmSwift
3let app = RealmSwift.App(id: "rchat-xxxxx") // TODO: Set the Realm application ID
4@main
5struct RChatApp: SwiftUI.App {
6 @StateObject var state = AppState()
7 var body: some Scene {
8 WindowGroup {
9 ContentView()
10 .environmentObject(state)
11 }
12 }
13}

#Application-Wide State: AppState

Views can pass state up and down the hierarchy. However, it can simplify state management by making some state available application-wide. In this app, we centralize this app-wide state data storage and control in an instance of the AppState class.

There's a lot going on in AppState.swift, and you can view the full file in the repo.

Let's start by looking at some of the AppState attributes:

1class AppState: ObservableObject {
2 ...
3 var userRealm: Realm?
4 var chatsterRealm: Realm?
5 var user: User?
6 ...
7}

user represents the user that's currently logged into the app (and Realm). We'll look at the User class later, but it includes the user's username, preferences, presence state, and a list of the conversations/chat rooms they're members of. If user is set to nil, then no user is logged in.

When logged in, the app opens two realms:

  • userRealm lets the user read and write just their own data from the Atlas User collection.
  • chatsterRealm enables the user to read data for every user from the Atlas Chatster collection.

The app uses the Realm SDK to interact with the back end Realm application to perform actions such as logging into Realm. Those operations can take some time as they involve accessing resources over the internet, and so we don't want the app to sit busy-waiting for a response. Instead, we use Combine publishers and subscribers to handle these events. loginPublisher, chatsterLoginPublisher, logoutPublisher, chatsterRealmPublisher, and userRealmPublisher are publishers to handle logging in, logging out, and opening realms for a user:

1class AppState: ObservableObject {
2 ...
3 let loginPublisher = PassthroughSubject<RealmSwift.User, Error>()
4 let chatsterLoginPublisher = PassthroughSubject<RealmSwift.User, Error>()
5 let logoutPublisher = PassthroughSubject<Void, Error>()
6 let chatsterRealmPublisher = PassthroughSubject<Realm, Error>()
7 let userRealmPublisher = PassthroughSubject<Realm, Error>()
8 ...
9}

When an AppState class is instantiated, the realms are initialized to nil and actions are assigned to each of the Combine publishers:

1init() {
2 _ = app.currentUser?.logOut()
3 userRealm = nil
4 chatsterRealm = nil
5 initChatsterLoginPublisher()
6 initChatsterRealmPublisher()
7 initLoginPublisher()
8 initUserRealmPublisher()
9 initLogoutPublisher()
10}

We'll later see that an event is sent to loginPublisher and chatsterLoginPublisher when a user has successfully logged into Realm. In AppState, we define what should be done when those events are received. For example, events received on loginPublisher trigger the opening of a realm with the partition set to user=<id of the user>, which in turn sends an event to userRealmPublisher:

1func initLoginPublisher() {
2loginPublisher
3 .receive(on: DispatchQueue.main)
4 .flatMap { user -> RealmPublishers.AsyncOpenPublisher in
5 self.shouldIndicateActivity = true
6 let realmConfig = user.configuration(partitionValue: "user=\(user.id)")
7 return Realm.asyncOpen(configuration: realmConfig)
8 }
9 .receive(on: DispatchQueue.main)
10 .map {
11 return $0
12 }
13 .subscribe(userRealmPublisher)
14 .store(in: &self.cancellables)
15}

When the realm has been opened and the realm sent to userRealmPublisher, the Realm struct is stored in the userRealm attribute and the local user is initialized with the User object retrieved from the realm:

1func initUserRealmPublisher() {
2 userRealmPublisher
3 .sink(receiveCompletion: { result in
4 if case let .failure(error) = result {
5 self.error = "Failed to log in and open user realm: \(error.localizedDescription)"
6 }
7 }, receiveValue: { realm in
8 print("User Realm User file location: \(realm.configuration.fileURL!.path)")
9 self.userRealm = realm
10 self.user = realm.objects(User.self).first
11 do {
12 try realm.write {
13 self.user?.presenceState = .onLine
14 }
15 } catch {
16 self.error = "Unable to open Realm write transaction"
17 }
18 self.shouldIndicateActivity = false
19 })
20 .store(in: &cancellables)
21}

chatsterLoginPublisher behaves in the same way, but for a realm that stores Chatster objects:

1func initChatsterLoginPublisher() {
2 chatsterLoginPublisher
3 .receive(on: DispatchQueue.main)
4 .flatMap { user -> RealmPublishers.AsyncOpenPublisher in
5 self.shouldIndicateActivity = true
6 let realmConfig = user.configuration(partitionValue: "all-users=all-the-users")
7 return Realm.asyncOpen(configuration: realmConfig)
8 }
9 .receive(on: DispatchQueue.main)
10 .map {
11 return $0
12 }
13 .subscribe(chatsterRealmPublisher)
14 .store(in: &self.cancellables)
15}
16
17func initChatsterRealmPublisher() {
18 chatsterRealmPublisher
19 .sink(receiveCompletion: { result in
20 if case let .failure(error) = result {
21 self.error = "Failed to log in and open chatster realm: \(error.localizedDescription)"
22 }
23 }, receiveValue: { realm in
24 print("Chatster Realm User file location: \(realm.configuration.fileURL!.path)")
25 self.chatsterRealm = realm
26 self.shouldIndicateActivity = false
27 })
28 .store(in: &cancellables)
29}

After logging out of Realm, we simply set the attributes to nil:

1func initLogoutPublisher() {
2 logoutPublisher
3 .receive(on: DispatchQueue.main)
4 .sink(receiveCompletion: { _ in
5 }, receiveValue: { _ in
6 self.user = nil
7 self.userRealm = nil
8 self.chatsterRealm = nil
9 })
10 .store(in: &cancellables)
11}

#Enabling Email/Password Authentication in the Realm App

After seeing what happens after a user has logged into Realm, we need to circle back and enable email/password authentication in the back end Realm app. Fortunately, it's straightforward to do.

From the Realm UI, select "Authentication" from the lefthand menu, followed by "Authentication Providers." Click the "Edit" button for "Email/Password":

Enable email/password authentication through the Atlas/Realm UI

Enable the provider and select "Automatically confirm users" and "Run a password reset function." Select "New function" and save without making any edits:

Configure email/password authentication through the Atlas/Realm UI

Don't forget to click on "REVIEW & DEPLOY" whenever you've made a change to the back end Realm app.

#Create User Document on User Registration

When a new user registers, we need to create a User document in Atlas that will eventually synchronize with a User object in the iOS app. Realm provides authentication triggers that can automate this.

Select "Triggers" and then click on "Add a Trigger":

Create a new Realm trigger through the Realm UI

Set the "Trigger Type" to "Authentication," provide a name, set the "Action Type" to "Create" (user registration), set the "Event Type" to "Function," and then select "New Function":

Configure authentication trigger through the Atlas/Realm UI

Name the function createNewUserDocument and add the code for the function:

1 exports = function({user}) {
2 const db = context.services.get("mongodb-atlas").db("RChat");
3 const userCollection = db.collection("User");
4 const partition = `user=${user.id}`;
5 const defaultLocation = context.values.get("defaultLocation");
6 const userPreferences = {
7 displayName: ""
8 };
9 const userDoc = {
10 _id: user.id,
11 partition: partition,
12 userName: user.data.email,
13 userPreferences: userPreferences,
14 location: context.values.get("defaultLocation"),
15 lastSeenAt: null,
16 presence:"Off-Line",
17 conversations: []
18 };
19 return userCollection.insertOne(userDoc)
20 .then(result => {
21 console.log(`Added User document with _id: ${result.insertedId}`);
22 }, error => {
23 console.log(`Failed to insert User document: ${error}`);
24 });
25 };

Note that we set the partition to user=<id of the user>, which matches the partition used when the iOS app opens the User realm.

"Save" then "REVIEW & DEPLOY."

#Define Realm Schema

Refer to Building a Mobile Chat App Using Realm – Data Architecture to understand more about the app's schema and partitioning rules. This article skips the analysis phase and just configures the Realm schema.

Browse to the "Rules" section in the Realm UI and click on "Add Collection." Set "Database Name" to RChat and "Collection Name" to User. We won't be accessing the User collection directly through Realm, so don't select a "Permissions Template." Click "Add Collection":


Create the RChat.User collection in Realm

At this point, I'll stop reminding you to click "REVIEW & DEPLOY!"

Select "Schema," paste in this schema, and then click "SAVE":

1{
2 "bsonType": "object",
3 "properties": {
4 "_id": {
5 "bsonType": "string"
6 },
7 "conversations": {
8 "bsonType": "array",
9 "items": {
10 "bsonType": "object",
11 "properties": {
12 "displayName": {
13 "bsonType": "string"
14 },
15 "id": {
16 "bsonType": "string"
17 },
18 "members": {
19 "bsonType": "array",
20 "items": {
21 "bsonType": "object",
22 "properties": {
23 "membershipStatus": {
24 "bsonType": "string"
25 },
26 "userName": {
27 "bsonType": "string"
28 }
29 },
30 "required": [
31 "membershipStatus",
32 "userName"
33 ],
34 "title": "Member"
35 }
36 },
37 "unreadCount": {
38 "bsonType": "long"
39 }
40 },
41 "required": [
42 "unreadCount",
43 "id",
44 "displayName"
45 ],
46 "title": "Conversation"
47 }
48 },
49 "lastSeenAt": {
50 "bsonType": "date"
51 },
52 "partition": {
53 "bsonType": "string"
54 },
55 "presence": {
56 "bsonType": "string"
57 },
58 "userName": {
59 "bsonType": "string"
60 },
61 "userPreferences": {
62 "bsonType": "object",
63 "properties": {
64 "avatarImage": {
65 "bsonType": "object",
66 "properties": {
67 "_id": {
68 "bsonType": "string"
69 },
70 "date": {
71 "bsonType": "date"
72 },
73 "picture": {
74 "bsonType": "binData"
75 },
76 "thumbNail": {
77 "bsonType": "binData"
78 }
79 },
80 "required": [
81 "_id",
82 "date"
83 ],
84 "title": "Photo"
85 },
86 "displayName": {
87 "bsonType": "string"
88 }
89 },
90 "required": [],
91 "title": "UserPreferences"
92 }
93 },
94 "required": [
95 "_id",
96 "partition",
97 "userName",
98 "presence"
99 ],
100 "title": "User"
101}
Defie the User schema through the Realm UI

Repeat for the Chatster schema:

1{
2 "bsonType": "object",
3 "properties": {
4 "_id": {
5 "bsonType": "string"
6 },
7 "avatarImage": {
8 "bsonType": "object",
9 "properties": {
10 "_id": {
11 "bsonType": "string"
12 },
13 "date": {
14 "bsonType": "date"
15 },
16 "picture": {
17 "bsonType": "binData"
18 },
19 "thumbNail": {
20 "bsonType": "binData"
21 }
22 },
23 "required": [
24 "_id",
25 "date"
26 ],
27 "title": "Photo"
28 },
29 "displayName": {
30 "bsonType": "string"
31 },
32 "lastSeenAt": {
33 "bsonType": "date"
34 },
35 "partition": {
36 "bsonType": "string"
37 },
38 "presence": {
39 "bsonType": "string"
40 },
41 "userName": {
42 "bsonType": "string"
43 }
44 },
45 "required": [
46 "_id",
47 "partition",
48 "presence",
49 "userName"
50 ],
51 "title": "Chatster"
52}

And for the ChatMessage collection:

1{
2 "bsonType": "object",
3 "properties": {
4 "_id": {
5 "bsonType": "string"
6 },
7 "author": {
8 "bsonType": "string"
9 },
10 "image": {
11 "bsonType": "object",
12 "properties": {
13 "_id": {
14 "bsonType": "string"
15 },
16 "date": {
17 "bsonType": "date"
18 },
19 "picture": {
20 "bsonType": "binData"
21 },
22 "thumbNail": {
23 "bsonType": "binData"
24 }
25 },
26 "required": [
27 "_id",
28 "date"
29 ],
30 "title": "Photo"
31 },
32 "location": {
33 "bsonType": "array",
34 "items": {
35 "bsonType": "double"
36 }
37 },
38 "partition": {
39 "bsonType": "string"
40 },
41 "text": {
42 "bsonType": "string"
43 },
44 "timestamp": {
45 "bsonType": "date"
46 }
47 },
48 "required": [
49 "_id",
50 "partition",
51 "text",
52 "timestamp"
53 ],
54 "title": "ChatMessage"
55}

#Enable Realm Sync

Realm Sync is used to synchronize objects between instances of the iOS app (and we'll extend this app to also include Android). It also syncs those objects with Atlas collections. Note that there are three options to create a Realm schema:

  1. Manually code the schema as a JSON schema document.
  2. Derive the schema from existing data stored in Atlas. (We don't yet have any data and so this isn't an option here.)
  3. Derive the schema from the Realm objects used in the mobile app.

We've already specified the schema and so will stick to the first option.

Select "Sync" and then select your Atlas cluster. Set the "Partition Key" to the partition attribute (it appears in the list as it's already in the schema for all three collections), and the rules for whether a user can sync with a given partition:


Enable Realm Sync through the Atlas/Realm UI

The "Read" rule controls whether a user can establish one-way read-only sync relationship to the mobile app for a given user and partition. In this case, the rule delegates this to a Realm function named canReadPartition:

1{
2 "%%true": {
3 "%function": {
4 "arguments": [
5 "%%partition"
6 ],
7 "name": "canReadPartition"
8 }
9 }
10}

The "Write" rule delegates to the canWritePartition:

1{
2 "%%true": {
3 "%function": {
4 "arguments": [
5 "%%partition"
6 ],
7 "name": "canWritePartition"
8 }
9 }
10}

Once more, we've already seen those functions in Building a Mobile Chat App Using Realm – Data Architecture but I'll include the code here for completeness.

canReadPartition:

1exports = function(partition) {
2 console.log(`Checking if can sync a read for partition = ${partition}`);
3 const db = context.services.get("mongodb-atlas").db("RChat");
4 const chatsterCollection = db.collection("Chatster");
5 const userCollection = db.collection("User");
6 const chatCollection = db.collection("ChatMessage");
7 const user = context.user;
8 let partitionKey = "";
9 let partitionVale = "";
10 const splitPartition = partition.split("=");
11 if (splitPartition.length == 2) {
12 partitionKey = splitPartition[0];
13 partitionValue = splitPartition[1];
14 console.log(`Partition key = ${partitionKey}; partition value = ${partitionValue}`);
15 } else {
16 console.log(`Couldn't extract the partition key/value from ${partition}`);
17 return false;
18 }
19 switch (partitionKey) {
20 case "user":
21 console.log(`Checking if partitionValue(${partitionValue}) matches user.id(${user.id}) – ${partitionKey === user.id}`);
22 return partitionValue === user.id;
23 case "conversation":
24 console.log(`Looking up User document for _id = ${user.id}`);
25 return userCollection.findOne({ _id: user.id })
26 .then (userDoc => {
27 if (userDoc.conversations) {
28 let foundMatch = false;
29 userDoc.conversations.forEach( conversation => {
30 console.log(`Checking if conversaion.id (${conversation.id}) === ${partitionValue}`)
31 if (conversation.id === partitionValue) {
32 console.log(`Found matching conversation element for id = ${partitionValue}`);
33 foundMatch = true;
34 }
35 });
36 if (foundMatch) {
37 console.log(`Found Match`);
38 return true;
39 } else {
40 console.log(`Checked all of the user's conversations but found none with id == ${partitionValue}`);
41 return false;
42 }
43 } else {
44 console.log(`No conversations attribute in User doc`);
45 return false;
46 }
47 }, error => {
48 console.log(`Unable to read User document: ${error}`);
49 return false;
50 });
51 case "all-users":
52 console.log(`Any user can read all-users partitions`);
53 return true;
54 default:
55 console.log(`Unexpected partition key: ${partitionKey}`);
56 return false;
57 }
58};

canWritePartition:

1exports = function(partition) {
2console.log(`Checking if can sync a write for partition = ${partition}`);
3const db = context.services.get("mongodb-atlas").db("RChat");
4const chatsterCollection = db.collection("Chatster");
5const userCollection = db.collection("User");
6const chatCollection = db.collection("ChatMessage");
7const user = context.user;
8let partitionKey = "";
9let partitionVale = "";
10const splitPartition = partition.split("=");
11if (splitPartition.length == 2) {
12 partitionKey = splitPartition[0];
13 partitionValue = splitPartition[1];
14 console.log(`Partition key = ${partitionKey}; partition value = ${partitionValue}`);
15} else {
16 console.log(`Couldn't extract the partition key/value from ${partition}`);
17 return false;
18}
19 switch (partitionKey) {
20 case "user":
21 console.log(`Checking if partitionKey(${partitionValue}) matches user.id(${user.id}) – ${partitionKey === user.id}`);
22 return partitionValue === user.id;
23 case "conversation":
24 console.log(`Looking up User document for _id = ${user.id}`);
25 return userCollection.findOne({ _id: user.id })
26 .then (userDoc => {
27 if (userDoc.conversations) {
28 let foundMatch = false;
29 userDoc.conversations.forEach( conversation => {
30 console.log(`Checking if conversaion.id (${conversation.id}) === ${partitionValue}`)
31 if (conversation.id === partitionValue) {
32 console.log(`Found matching conversation element for id = ${partitionValue}`);
33 foundMatch = true;
34 }
35 });
36 if (foundMatch) {
37 console.log(`Found Match`);
38 return true;
39 } else {
40 console.log(`Checked all of the user's conversations but found none with id == ${partitionValue}`);
41 return false;
42 }
43 } else {
44 console.log(`No conversations attribute in User doc`);
45 return false;
46 }
47 }, error => {
48 console.log(`Unable to read User document: ${error}`);
49 return false;
50 });
51 case "all-users":
52 console.log(`No user can write to an all-users partitions`);
53 return false;
54 default:
55 console.log(`Unexpected partition key: ${partitionKey}`);
56 return false;
57 }
58};

To create these functions, select "Functions" and click "Create New Function." Make sure you type the function name precisely, set "Authentication" to "System," and turn on the "Private" switch (which means it can't be called directly from external services such as our mobile app):

Define a Realm function through the Atlas/Realm UI

#Linking User and Chatster Documents

As described in Building a Mobile Chat App Using Realm – Data Architecture, there are relationships between different User and Chatster documents. Now that we've defined the schemas and enabled Realm Sync, it's a convenient time to add the Realm function and database trigger to maintain those relationships.

Create a Realm function named userDocWrittenTo, set "Authentication" to "System," and make it private. This article is aiming to focus on the iOS app more than the back end Realm app, and so we won't delve into this code:

1exports = function(changeEvent) {
2 const db = context.services.get("mongodb-atlas").db("RChat");
3 const chatster = db.collection("Chatster");
4 const userCollection = db.collection("User");
5 const docId = changeEvent.documentKey._id;
6 const user = changeEvent.fullDocument;
7 let conversationsChanged = false;
8 console.log(`Mirroring user for docId=${docId}. operationType = ${changeEvent.operationType}`);
9 switch (changeEvent.operationType) {
10 case "insert":
11 case "replace":
12 case "update":
13 console.log(`Writing data for ${user.userName}`);
14 let chatsterDoc = {
15 _id: user._id,
16 partition: "all-users=all-the-users",
17 userName: user.userName,
18 lastSeenAt: user.lastSeenAt,
19 presence: user.presence
20 };
21 if (user.userPreferences) {
22 const prefs = user.userPreferences;
23 chatsterDoc.displayName = prefs.displayName;
24 if (prefs.avatarImage && prefs.avatarImage._id) {
25 console.log(`Copying avatarImage`);
26 chatsterDoc.avatarImage = prefs.avatarImage;
27 console.log(`id of avatarImage = ${prefs.avatarImage._id}`);
28 }
29 }
30 chatster.replaceOne({ _id: user._id }, chatsterDoc, { upsert: true })
31 .then (() => {
32 console.log(`Wrote Chatster document for _id: ${docId}`);
33 }, error => {
34 console.log(`Failed to write Chatster document for _id=${docId}: ${error}`);
35 });
36
37 if (user.conversations && user.conversations.length > 0) {
38 for (i = 0; i < user.conversations.length; i++) {
39 let membersToAdd = [];
40 if (user.conversations[i].members.length > 0) {
41 for (j = 0; j < user.conversations[i].members.length; j++) {
42 if (user.conversations[i].members[j].membershipStatus == "User added, but invite pending") {
43 membersToAdd.push(user.conversations[i].members[j].userName);
44 user.conversations[i].members[j].membershipStatus = "Membership active";
45 conversationsChanged = true;
46 }
47 }
48 }
49 if (membersToAdd.length > 0) {
50 userCollection.updateMany({userName: {$in: membersToAdd}}, {$push: {conversations: user.conversations[i]}})
51 .then (result => {
52 console.log(`Updated ${result.modifiedCount} other User documents`);
53 }, error => {
54 console.log(`Failed to copy new conversation to other users: ${error}`);
55 });
56 }
57 }
58 }
59 if (conversationsChanged) {
60 userCollection.updateOne({_id: user._id}, {$set: {conversations: user.conversations}});
61 }
62 break;
63 case "delete":
64 chatster.deleteOne({_id: docId})
65 .then (() => {
66 console.log(`Deleted Chatster document for _id: ${docId}`);
67 }, error => {
68 console.log(`Failed to delete Chatster document for _id=${docId}: ${error}`);
69 });
70 break;
71 }
72};

Set up a database trigger to execute the new function whenever anything in the User collection changes:

Add a database triggert through the Atlas/Realm UI

#Registering and Logging in From the iOS App

We've now created enough of the back end Realm app that mobile apps can now register new Realm users and use them to log into the app.

The app's top-level SwiftUI view is ContentView, which decides which sub-view to show based on whether our AppState environment object indicates that a user is logged in or not:

1@EnvironmentObject var state: AppState
2...
3if state.loggedIn {
4 if (state.user != nil) && !state.user!.isProfileSet || showingProfileView {
5 SetProfileView(isPresented: $showingProfileView)
6 } else {
7 ConversationListView()
8 .navigationBarTitle("Chats", displayMode: .inline)
9 .navigationBarItems(
10 trailing: state.loggedIn && !state.shouldIndicateActivity ? UserAvatarView(
11 photo: state.user?.userPreferences?.avatarImage,
12 online: true) { showingProfileView.toggle() } : nil
13 )
14 }
15} else {
16 LoginView()
17}
18...

When first run, no user is logged in and so LoginView is displayed.

Note that AppState.loggedIn checks whether a user is currently logged into the Realm app:

1var loggedIn: Bool {
2 app.currentUser != nil && app.currentUser?.state == .loggedIn
3 && userRealm != nil && chatsterRealm != nil
4}

The UI for LoginView contains cells to provide the user's email address and password, a radio button to indicate whether this is a new user, and a button to register or log in a user:

Define a Realm function through the Atlas/Realm UI

Clicking the button executes one of two functions:

1...
2CallToActionButton(
3 title: newUser ? "Register User" : "Log In",
4 action: { self.userAction(username: self.username, password: self.password) })
5...
6private func userAction(username: String, password: String) {
7 state.shouldIndicateActivity = true
8 if newUser {
9 signup(username: username, password: password)
10 } else {
11 login(username: username, password: password)
12 }
13}

signup makes an asynchronous call to the Realm SDK to register the new user. Through a Combine pipeline, signup receives an event when the registration completes, which triggers it to invoke the login function:

1private func signup(username: String, password: String) {
2 if username.isEmpty || password.isEmpty {
3 state.shouldIndicateActivity = false
4 return
5 }
6 self.state.error = nil
7 app.emailPasswordAuth.registerUser(email: username, password: password)
8 .receive(on: DispatchQueue.main)
9 .sink(receiveCompletion: {
10 state.shouldIndicateActivity = false
11 switch $0 {
12 case .finished:
13 break
14 case .failure(let error):
15 self.state.error = error.localizedDescription
16 }
17 }, receiveValue: {
18 self.state.error = nil
19 login(username: username, password: password)
20 })
21 .store(in: &state.cancellables)
22}

The login function uses the Realm SDK to log in the user asynchronously. If/when the Realm login succeeds, the Combine pipeline sends the Realm user to the chatsterLoginPublisher and loginPublisher publishers (recall that we've seen how those are handled within the AppState class):

1private func login(username: String, password: String) {
2 if username.isEmpty || password.isEmpty {
3 state.shouldIndicateActivity = false
4 return
5 }
6 self.state.error = nil
7 app.login(credentials: .emailPassword(email: username, password: password))
8 .receive(on: DispatchQueue.main)
9 .sink(receiveCompletion: {
10 state.shouldIndicateActivity = false
11 switch $0 {
12 case .finished:
13 break
14 case .failure(let error):
15 self.state.error = error.localizedDescription
16 }
17 }, receiveValue: {
18 self.state.error = nil
19 state.chatsterLoginPublisher.send($0)
20 state.loginPublisher.send($0)
21 })
22 .store(in: &state.cancellables)
23}

#Saving the User Profile

On being logged in for the first time, the user is presented with SetProfileView. (They can also return here later by clicking on their avatar.) This is a SwiftUI sheet where the user can set their profile and preferences by interacting with the UI and then clicking "Save User Profile":

Save user profile information through the iOS app

When the view loads, the UI is populated with any existing profile information found in the User object in the AppState environment object:

1...
2@EnvironmentObject var state: AppState
3...
4.onAppear { initData() }
5...
6private func initData() {
7 displayName = state.user?.userPreferences?.displayName ?? ""
8 photo = state.user?.userPreferences?.avatarImage
9}

As the user updates the UI elements, the Realm User object isn't changed. It's only when they click "Save User Profile" that we update the User object. Note that it uses the userRealm that was initialized when the user logged in to open a Realm write transaction before making the change:

1...
2@EnvironmentObject var state: AppState
3...
4CallToActionButton(title: "Save User Profile", action: saveProfile)
5...
6private func saveProfile() {
7 if let realm = state.userRealm {
8 state.shouldIndicateActivity = true
9 do {
10 try realm.write {
11 state.user?.userPreferences?.displayName = displayName
12 if photoAdded {
13 guard let newPhoto = photo else {
14 print("Missing photo")
15 state.shouldIndicateActivity = false
16 return
17 }
18 state.user?.userPreferences?.avatarImage = newPhoto
19 }
20 state.user?.presenceState = .onLine
21 }
22 } catch {
23 state.error = "Unable to open Realm write transaction"
24 }
25 }
26 state.shouldIndicateActivity = false
27}

Once saved to the local realm, Realm Sync copies changes made to the User object to the associated User document in Atlas.

#List of Conversations

Once the user has logged in and set up their profile information, they're presented with the ConversationListView:

1if state.loggedIn {
2 if (state.user != nil) && !state.user!.isProfileSet || showingProfileView {
3 SetProfileView(isPresented: $showingProfileView)
4 } else {
5 ConversationListView()
6 .navigationBarTitle("Chats", displayMode: .inline)
7 .navigationBarItems(
8 trailing: state.loggedIn && !state.shouldIndicateActivity ? UserAvatarView(
9 photo: state.user?.userPreferences?.avatarImage,
10 online: true) { showingProfileView.toggle() } : nil
11 )
12 }
13} else {
14 LoginView()
15}

ConversationListView displays a list of all the conversations that the user is currently a member of (initially none) by looping over conversations within their User Realm object:

1if let conversations = state.user?.conversations.freeze().sorted(by: sortDescriptors) {
2 List {
3 ForEach(conversations) { conversation in
4 Button(action: {
5 self.conversation = conversation
6 showConversation.toggle()
7 }) {
8 ConversationCardView(
9 conversation: conversation,
10 lastSync: lastSync)
11 }
12 }
13 }
14 ...
15}

At any time, another user can include you in a new group conversation. This view needs to reflect those changes as they happen:

iOS app automatically shows new conversation when added by another user

When the other user adds us to a conversation, our User document is updated automatically through the magic of Realm Sync and our Realm trigger; but we need to give SwiftUI a nudge to refresh the current view. We do that by registering for Realm notifications and updating the lastSync state variable on each change. We register for notifications when the view appears and deregister when it disappears:

1@State var lastSync: Date?
2...
3var body: some View {
4 VStack {
5 ...
6 if let lastSync = lastSync {
7 LastSync(date: lastSync)
8 }
9 ...
10 }
11 ...
12 .onAppear { watchRealms() }
13 .onDisappear { stopWatching() }
14}
15
16private func watchRealms() {
17 if let userRealm = state.userRealm {
18 realmUserNotificationToken = userRealm.observe {_, _ in
19 lastSync = Date()
20 }
21 }
22 if let chatsterRealm = state.chatsterRealm {
23 realmChatsterNotificationToken = chatsterRealm.observe { _, _ in
24 lastSync = Date()
25 }
26 }
27}
28
29private func stopWatching() {
30 if let userToken = realmUserNotificationToken {
31 userToken.invalidate()
32 }
33 if let chatsterToken = realmChatsterNotificationToken {
34 chatsterToken.invalidate()
35 }
36}

#Creating New Conversations

NewConversationView is another view that lets the user provide a number of details which are then saved to Realm when the "Save" button is tapped. What's new is that it uses Realm to search for all users that match a filter pattern:

1private func searchUsers() {
2 var candidateChatsters: Results<Chatster>
3 if let chatsterRealm = state.chatsterRealm {
4 let allChatsters = chatsterRealm.objects(Chatster.self)
5 if candidateMember == "" {
6 candidateChatsters = allChatsters
7 } else {
8 let predicate = NSPredicate(format: "userName CONTAINS[cd] %@", candidateMember)
9 candidateChatsters = allChatsters.filter(predicate)
10 }
11 candidateMembers = []
12 candidateChatsters.forEach { chatster in
13 if !members.contains(chatster.userName) && chatster.userName != state.user?.userName {
14 candidateMembers.append(chatster.userName)
15 }
16 }
17 }
18}

#Conversation Status


Presence and conversation status being updated automatically in the iOS app

When the status of a conversation changes (users go online/offline or new messages are received), the card displaying the conversation details should update.

We already have a Realm function to set the presence status in Chatster documents/objects when users log on or off. All Chatster objects are readable by all users, and so ConversationCardContentsView can already take advantage of that information.

The conversation.unreadCount is part of the User object and so we need another Realm trigger to update that whenever a new chat message is posted to a conversation.

We add a new Realm function chatMessageChange that's configured as private and with "System" authentication (just like our other functions). This is the function code that will increment the unreadCount for all User documents for members of the conversation:

1exports = function(changeEvent) {
2 if (changeEvent.operationType != "insert") {
3 console.log(`ChatMessage ${changeEvent.operationType} event – currently ignored.`);
4 return;
5 }
6
7 console.log(`ChatMessage Insert event being processed`);
8 let userCollection = context.services.get("mongodb-atlas").db("RChat").collection("User");
9 let chatMessage = changeEvent.fullDocument;
10 let conversation = "";
11
12 if (chatMessage.partition) {
13 const splitPartition = chatMessage.partition.split("=");
14 if (splitPartition.length == 2) {
15 conversation = splitPartition[1];
16 console.log(`Partition/conversation = ${conversation}`);
17 } else {
18 console.log("Couldn't extract the conversation from partition ${chatMessage.partition}");
19 return;
20 }
21 } else {
22 console.log("partition not set");
23 return;
24 }
25
26 const matchingUserQuery = {
27 conversations: {
28 $elemMatch: {
29 id: conversation
30 }
31 }
32 };
33
34 const updateOperator = {
35 $inc: {
36 "conversations.$[element].unreadCount": 1
37 }
38 };
39
40 const arrayFilter = {
41 arrayFilters:[
42 {
43 "element.id": conversation
44 }
45 ]
46 };
47
48 userCollection.updateMany(matchingUserQuery, updateOperator, arrayFilter)
49 .then ( result => {
50 console.log(`Matched ${result.matchedCount} User docs; updated ${result.modifiedCount}`);
51 }, error => {
52 console.log(`Failed to match and update User docs: ${error}`);
53 });
54};

That function should be invoked by a new Realm database trigger (ChatMessageChange) to fire whenever a document is inserted into the RChat.ChatMessage collection.

#Within the Chat Room


Chat messages being exchanged betwen users of the iOS app

ChatRoomView has a lot of similarities with ConversationListView, but with one fundamental difference. Each conversation/chat room has its own partition, and so when opening a conversation, you need to open a new realm and observe for changes in it:

1@EnvironmentObject var state: AppState
2...
3var body: some View {
4 VStack {
5 ...
6 }
7 .onAppear { loadChatRoom() }
8 .onDisappear { closeChatRoom() }
9}
10
11private func loadChatRoom() {
12 clearUnreadCount()
13 if let user = app.currentUser, let conversation = conversation {
14 scrollToBottom()
15 self.state.shouldIndicateActivity = true
16 let realmConfig = user.configuration(partitionValue: "conversation=\(conversation.id)")
17 Realm.asyncOpen(configuration: realmConfig)
18 .receive(on: DispatchQueue.main)
19 .sink(receiveCompletion: { result in
20 if case let .failure(error) = result {
21 self.state.error = "Failed to open ChatMessage realm: \(error.localizedDescription)"
22 state.shouldIndicateActivity = false
23 }
24 }, receiveValue: { realm in
25 chatRealm = realm
26 chats = realm.objects(ChatMessage.self).sorted(byKeyPath: "timestamp")
27 realmChatsNotificationToken = realm.observe {_, _ in
28 scrollToBottom()
29 clearUnreadCount()
30 lastSync = Date()
31 }
32 scrollToBottom()
33 state.shouldIndicateActivity = false
34 })
35 .store(in: &self.state.cancellables)
36 }
37}

Note that we only open a Conversation realm when the user opens the associated view because having too many realms open concurrently can exhaust resources. It's also important that we stop observing the realm by setting it to nil when leaving the view:

1@EnvironmentObject var state: AppState
2...
3var body: some View {
4 VStack {
5 ...
6 }
7 .onAppear { loadChatRoom() }
8 .onDisappear { closeChatRoom() }
9}
10
11private func closeChatRoom() {
12 clearUnreadCount()
13 if let token = realmChatsterNotificationToken {
14 token.invalidate()
15 }
16 if let token = realmChatsNotificationToken {
17 token.invalidate()
18 }
19 chatRealm = nil
20}

To send a message, all the app needs to do is to add the new chat message to Realm. Realm Sync will then copy it to Atlas, where it is then synced to the other users:

1private func sendMessage(text: String, photo: Photo?, location: [Double]) {
2 if let conversation = conversation {
3 let chatMessage = ChatMessage(conversationId: conversation.id,
4 author: state.user?.userName ?? "Unknown",
5 text: text,
6 image: photo,
7 location: location)
8 if let chatRealm = chatRealm {
9 do {
10 try chatRealm.write {
11 chatRealm.add(chatMessage)
12 }
13 } catch {
14 state.error = "Unable to open Realm write transaction"
15 }
16 } else {
17 state.error = "Cannot save chat message as realm is not set"
18 }
19 }
20}

#Summary

In this article, we've gone through the key steps you need to take when building a mobile app using Realm, including:

  • Managing the user lifecycle: registering, authenticating, logging in, and logging out.
  • Managing and storing user profile information.
  • Adding objects to Realm.
  • Performing searches on Realm data.
  • Syncing data between your mobile apps and with MongoDB Atlas.
  • Reacting to data changes synced from other devices.
  • Adding some back end magic using Realm triggers and functions.

There's a lot of code and functionality that hasn't been covered in this article, and so it's worth looking through the rest of the app to see how to use features such as these from a SwiftUI iOS app:

  • Location data
  • Maps
  • Camera and photo library
  • Actions when minimizing your app
  • Notifications

We wrote the iOS version of the app first, but we plan on adding an Android (Kotlin) version soon – keep checking the developer hub and the repo for updates.

#References

If you have questions, please head to our developer community website where the MongoDB engineers and the MongoDB community will help you build your next big idea with MongoDB.

More from this series

Building a Mobile Chat App With Realm and SwiftUI
  • Building a Mobile Chat App Using Realm – Data Architecture
  • Building a Mobile Chat App Using Realm – Integrating Realm into Your App
  • Building a Mobile Chat App Using Realm – The New and Easier Way
MongoDB Icon
  • Developer Hub
  • Documentation
  • University
  • Community Forums

© MongoDB, Inc.