Let's take a look at the User module.

User

The User module is responsible for Proton's user-related functionalities.

The functionalities we need are: follow user, unfollow user, get following list, get followers list, create and update user profiles, as well as user search functionality.


To implement the functionality of users following each other, we first need to store the follow relationships between users. We can use a directed graph as the data structure.

For the user-following functionality, we can model it as a directed graph:

img

A directed graph can visually represent the follow relationships between users, making it easy to understand and implement related algorithms.

Graph algorithms can also be used to analyze social relationships, such as finding the shortest path between nodes or checking who among the users you follow is followed by them.


Define user profiles: types.mo

Before the directed graph, we need to define some type aliases to list the information in user profiles. In Motoko, use the type keyword to create type aliases, giving a new name to a type to make the code more straightforward and understandable.

Referring to the previous LinkedUp, define in a separate module and import it in the file using the module.

Now, let's define the vertices of the directed graph (users) and user profiles.

import Principal "mo:base/Principal"; // Import relevant content from the Motoko base library
import Time "mo:base/Time";

module {

    public type Vertex = Principal;
    public type UserId = Principal;
    public type Time = Time.Time;

    public type NewProfile = {
        name: Text;
        biography: Text;
        company: Text;
        education: Text;
        imgUrl: Text;
        feedCanister: ?Principal;
    };

    public type Profile = {
        id: UserId;
        name: Text;
        biography: Text;
        company: Text;
        education: Text;
        imgUrl: Text;
        feedCanister: ?Principal;
    };

};

Import statement:

import Principal "mo:base/Principal";

This line of code means importing a module from Motoko's standard library, specifically the Principal module, which is related to identity and can be considered as the ID of users or services on the IC.

Directed Graph: digraph.mo

Now, let's create the digraph.mo file and define a class Digraph to represent the follow relationships between users.

Write down the types defined earlier:

module {
  type Vertex = Types.Vertex;
};

vertexList stores all the vertices, and edgeList stores all the directed edges, representing the follow relationships:

type Vertex = Types.Vertex;

public class Digraph() {
    var vertexList: [Vertex] = []; // Vertices
    var edgeList: [(Vertex, Vertex)] = []; // Directed edges
};

addVertex is used to add vertices (users), and addEdge is used to add edges (follow relationships), using assert to ensure the uniqueness of edges:

type Vertex = Types.Vertex;

public class Digraph() {
    var vertexList: [Vertex] = []; // Vertices
    var edgeList: [(Vertex, Vertex)] = []; // Directed edges

    // The public keyword indicates that this function is accessible to anyone
    // If private, it can only be called by code within the Canister, not publicly accessible
    public func addVertex(vertex: Vertex) {
      vertexList := Array.append<Vertex>(vertexList, [vertex]);
    };

    // Add a directed edge from fromVertex to toVertex
    public func addEdge(fromVertex: Vertex, toVertex: Vertex) {
      // Check if it already exists
      assert(Array.find<(Vertex, Vertex)>(edgeList, func((x, y): (Vertex, Vertex)): Bool {
        x == fromVertex and y == toVertex
      }) == null);
      edgeList := Array.append<(Vertex, Vertex)>(edgeList, [(fromVertex, toVertex)]);
    };

};

getForwardAdjacent and getReverseAdjacent are used to get forward and reverse adjacent vertices, representing the user's following list and followers list, implemented by iterating through the edge list:

import Array "mo:base/Array";
import Iter "mo:base/Iter";
import Types "./types";

module {

  type Vertex = Types.Vertex;

  public class Digraph() {

    var vertexList: [Vertex] = []; // Vertices
    var edgeList: [(Vertex, Vertex)] = []; // Directed edges

    public func addVertex(vertex: Vertex) {
      vertexList := Array.append<Vertex>(vertexList, [vertex]);
    };

    // Add a directed edge from fromVertex to toVertex
    public func addEdge(fromVertex: Vertex, toVertex: Vertex) {
      // Check if it already exists
      assert(Array.find<(Vertex, Vertex)>(edgeList, func((x, y): (Vertex, Vertex)): Bool {
        x == fromVertex and y == toVertex
      }) == null);
      edgeList := Array.append<(Vertex, Vertex)>(edgeList, [(fromVertex, toVertex)]);
    };

    // Get vertices adjacent to the forward edge, i.e., get someone's following list
    public func getForwardAdjacent(vertex: Vertex): [Vertex] {
      var adjacencyList: [Vertex] = [];
      for ((fromVertex, toVertex) in Iter.fromArray<(Vertex, Vertex)>(edgeList)) {
        if (fromVertex == vertex) {
          adjacencyList := Array.append<Vertex>(adjacencyList, [toVertex]);
        };
      };
      adjacencyList
    };
    
    // Get vertices adjacent to the reverse edge, i.e., get someone's followers list
    public func getReverseAdjacent(vertex: Vertex): [Vertex] {
      var adjacencyList: [Vertex] = [];
      for ((fromVertex, toVertex) in Iter.fromArray<(Vertex, Vertex)>(edgeList)) {
        if (toVertex == vertex) {
          adjacencyList := Array.append<Vertex>(adjacencyList, [fromVertex]);
        };
      };
      adjacencyList
    };

  };
  
};

Now, with the directed graph storing user relationships, we also need a place to store user profiles.


Storing User Information: database.mo

Let's create another file specifically for storing user profiles: database.mo.

Define a storage class Directory for user information, using HashMap to store user IDs and their corresponding profiles:

public class Directory() {
    let hashMap = HashMap.HashMap<UserId, Profile>(1, isEq, Principal.hash);
};

A hash table, also known as a hash map, is a data structure that allows direct access to data based on key values. A hash table calculates a function (known as a hash function) for a key value, mapping the queried data to a position for access, thus speeding up the search for a specific key value.

The working principle of a hash table can be explained with an intuitive example:

Suppose there is a dictionary with a large amount of information about Chinese characters. To quickly find a specific character, a table is created initially arranged alphabetically by the pinyin letters of each character (similar to the "Pinyin Check Character Table" at the beginning of the dictionary). This establishes a kind of functional relationship between each character and its pinyin letters. When searching for a character, it is only necessary to locate the first letter, the second letter, the third letter, and so on, in this table. Most of the time, it's even possible to determine the accurate position of the character in the dictionary without searching through every letter of its pinyin. In this example, "finding the nth letter of the pinyin" is analogous to the hash function's rule, and the "Pinyin Check Character Table" can be understood as a kind of hash table.

There are also some functions to implement the functionalities of creating user profiles, updating user profiles, retrieving individual user profiles, retrieving multiple user profiles, and searching for users based on keywords.

The createOne and updateOne functions are used to create and update user profiles by calling the put function to add user IDs and profiles to the HashMap.

public class Directory() {
  let hashMap = HashMap.HashMap<UserId, Profile>(1, isEq, Principal.hash);
  
  public func createOne(userId: UserId, profile: NewProfile) {
    hashMap.put(userId, makeProfile(userId, profile));
  };

  public func updateOne(userId: UserId, profile: NewProfile) {
    hashMap.put(userId, makeProfile(userId, profile));
  };
  
  // Other functions...
}

The findOne function is used to retrieve an individual user profile by calling the get function from the HashMap.

public func findOne(userId: UserId): ?Profile {
  hashMap.get(userId)
};

The findMany function is used to retrieve multiple user profiles by iterating through the list of user IDs and calling the getProfile function.

public func findMany(userIds: [UserId]): [Profile] {
  func getProfile(userId: UserId): Profile {
    Option.unwrap<Profile>(hashMap.get(userId))
  };
  Array.map<UserId, Profile>(userIds, getProfile)
};

The findBy function is used to search for users based on keywords, iterating through the user profiles in the HashMap and finding matching profiles.

public func findBy(term: Text): [Profile] {
  var profiles: [Profile] = [];
  for ((id, profile) in hashMap.entries()) {
    if (includesText(profile.name, term)) {
      profiles := Array.append<Profile>(profiles, [profile]);
    };
  };
  profiles
};

The auxiliary function makeProfile is used to create an instance of a user profile, and includesText is used to determine if one string contains another.

func makeProfile(userId: UserId, profile: NewProfile): Profile {
  {
    id = userId;
    name = profile.name;
    biography = profile.biography;
    company = profile.company;
    education = profile.education;
    imgUrl = profile.imgUrl;
    feedCanister = profile.feedCanister;
  }
};

func includesText(string: Text, term: Text): Bool {
  let stringArray = Iter.toArray<Char>(string.chars());
  let termArray = Iter.toArray<Char>(term.chars());

  var i = 0;
  var j = 0;

  while (i < stringArray.size() and j < termArray.size()) {
    if (stringArray[i] == termArray[j]) {
      i += 1;
      j += 1;
      if (j == termArray.size()) { return true; }
    } else {
      i += 1;
      j := 0;
    }
  };
  false
};

database.mo:

import Array "mo:base/Array";
import Hash "mo:base/Hash";
import HashMap "mo:base/HashMap";
import Iter "mo:base/Iter";
import Nat "mo:base/Nat";
import Option "mo:base/Option";
import Principal "mo:base/Principal";
import Time "mo:base/Time";
import Types "./types";
import TrieMap "mo:base/TrieMap";
import TrieSet "mo:base/TrieSet";

module {
  
  type NewProfile = Types.NewProfile;
  type Profile = Types.Profile;
  type UserId = Types.UserId;
  type Time = Time.Time;

  public class Directory() {

    let hashMap = HashMap.HashMap<UserId, Profile>(1, isEq, Principal.hash);

    public func createOne(userId: UserId, profile: NewProfile) {
      hashMap.put(userId, makeProfile(userId, profile));
    };

    public func updateOne(userId: UserId, profile: NewProfile) {
      hashMap.put(userId, makeProfile(userId, profile));
    };

    public func findOne(userId: UserId): ?Profile {
      hashMap.get(userId)
    };

    public func findMany(userIds: [UserId]): [Profile] {
      func getProfile(userId: UserId): Profile {
        Option.unwrap<Profile>(hashMap.get(userId))
      };
      Array.map<UserId, Profile>(userIds, getProfile)
    };

    public func findBy(term: Text): [Profile] {
      var profiles: [Profile] = [];
      for ((id, profile) in hashMap.entries()) {
        if (includesText(profile.name, term)) {
          profiles := Array.append<Profile>(profiles, [profile]);
        };
      };
      profiles
    };

    // Helpers

    func makeProfile(userId: UserId, profile: NewProfile): Profile {
      {
        id = userId;
        name = profile.name;
        biography = profile.biography;
        company = profile.company;
        education = profile.education;
        imgUrl = profile.imgUrl;
        feedCanister = profile.feedCanister;
      }
    };

    func includesText(string: Text, term: Text): Bool {
      let stringArray = Iter.toArray<Char>(string.chars());
      let termArray = Iter.toArray<Char>(term.chars());

      var i = 0;
      var j = 0;

      while (i < stringArray.size() and j < termArray.size()) {
        if (stringArray[i] == termArray[j]) {
          i += 1;
          j += 1;
          if (j == termArray.size()) { return true; }
        } else {
          i += 1;
          j := 0;
        }
      };
      false
    };
  };

  func isEq(x: UserId, y: UserId): Bool { x == y };
};


Integrating All Functions: main.mo

After completing types.mo, digraph.mo, and database.mo, the final step is to integrate the aforementioned modules using the actor and implement all functionalities.

img

The Digraph class implements a directed graph data structure for storing relationships between users. The Database class implements a database for user profiles.

image-20240208211521682

Now, we use the User actor class to implement the main functionalities of the user module. The User class represents a user in the social network, including functions such as following other users, retrieving the list of followers, obtaining the list of followers, creating and updating user profiles, and searching for users.

Relationships between modules:

image-20240121114437945

The User actor relies on the Digraph and Database classes to implement the underlying data structures.


In Motoko, an actor is a special type that can be thought of as a small machine or service capable of executing code. Each actor has a unique Principal ID, and it can be considered an entry point for a microservice in a service-oriented architecture.

actor class Feed(...) = this { ... }

Here, we see an actor class, which is a special form of an actor that can accept parameters to create new actor instances. This is similar to classes in object-oriented programming.

According to the previous design, the functionalities we want to implement are summarized as follows:

  • Allow one user to follow another user, updating the directed graph's edges and notifying the followed user's Feed Canister to update the followers' list.
  • Query the following list and followers list of a specific user through the directed graph.
  • Add and update a user's profile.
  • Query user profiles.
image-20240208211810805

Here is the English translation:


We create a rootFeedCanister to record the Root Feed Canister.

The init function is used to initialize the user's root Feed Canister.

The getRootFeedCanister function is used to retrieve the user's root Feed Canister.

// Define UserActor
actor class User() = this {
    
    // Use a variable to represent the Root Feed Canister (used to store user feed data)
    // Principal.fromText is used here, so import the module above: import Principal
    stable var rootFeedCanister = Principal.fromText("2vxsx-fae");

    // Initialization function, set Root Feed Canister
    public shared({caller}) func init(_rootFeedCanister: Principal) {
        rootFeedCanister := _rootFeedCanister;
    };

    // Query Root Feed Canister
    // The query keyword indicates a query function, used to query data
    // The response is very fast, does not go through consensus, and does not change the Canister state
    public query func getRootFeedCanister(): async Principal {
        rootFeedCanister
    };
}

Public, private, and shared functions:

Functions in Motoko can be public (public), private (private), similar to access control in other programming languages. shared functions are a special type of public function that can communicate between different actors.

public shared({caller}) func init(...) { ... }

This function can be called by an external actor, and {caller} is a pattern match used to obtain the identity of the caller.


The follow function is used for a user to follow another user, involving updating the follower's Feed Canister and updating the directed graph.

// Function for a user to follow another user
public shared({caller}) func follow(user: Vertex): async () {
    // Check if Root Feed Canister is anonymous
    assert(not Principal.isAnonymous(rootFeedCanister));
    
    // Create a RootFeedActor instance based on Root Feed Canister
    let rootFeedActor: RootFeedActor = actor(Principal.toText(rootFeedCanister));
    
    // Iterate through all user Feed Canisters in Root Feed Actor
    for((_, _feedCanister) in (await rootFeedActor.getAllUserFeedCanister()).vals()) {
        
        // Create a FeedActor instance based on Feed Canister
        let feedActor: FeedActor = actor(Principal.toText(_feedCanister));
        
        // Ignore the return value when calling followers, the list of followers is in the Feed Canister
        ignore feedActor.updateFollowers(Array.append(
            graph.getReverseAdjacent(user),
            [caller]
        ));
    };
    // Add an edge in the graph, indicating that the user is following another user
    graph.addEdge(caller, user);
};

The shared keyword is used to declare a function or type as shared. This means that they can be shared and used between different actors.

If you declare a public shared function in one actor, this function can be called by other actors and modify their state through asynchronous message passing.

public shared func oneway() { }; is an example of a function with no return type that can modify the actor's state and returns () regardless of success.

Motoko also defines a series of shared types. Only shared types can be used as parameters and return values for public shared functions. These shared types include all primitive types (except the Error type), the Option type, tuple types, immutable array types, object types, etc. For example, Nat and Text are shared types.


The getFollowingList and getFollowersList functions retrieve the user's following list and followers list.

The createProfile and updateProfile functions create and update user profiles.

The getProfile function retrieves the user's profile.

The searchProfile function searches for users based on keywords.

Overall, this is how it looks:

image-20240208212802310

The complete main.mo file is as follows:

import Array "mo:base/Array";
import Digraph "./digraph";
import Database "./database";
import Principal "mo:base/Principal";
import Types "./types";

// Define UserActor
actor class User() = this {
    
    // Use a variable to represent the Root Feed Canister (used to store user feed data)
    // Principal.fromText is used here, so import the module above: import Principal
    stable var rootFeedCanister = Principal.fromText("2vxsx-fae");

    // Initialization function, set Root Feed Canister
    public shared({caller}) func init(_rootFeedCanister: Principal) {
        rootFeedCanister := _rootFeedCanister;
    };

    // Query Root Feed Canister
    // The query keyword indicates a query function, used to query data
    // The response is very fast, does not go through consensus, and does not change the Canister state
    public query func getRootFeedCanister(): async Principal {
        rootFeedCanister
    };

// ==================== Follow Info ====================

    // Define type aliases
    type Vertex = Types.Vertex;
    type RootFeedActor = Types.RootFeedActor;
    type FeedActor = Types.FeedActor;

    // Create a directed graph instance
    var graph: Digraph.Digraph = Digraph.Digraph();

    // Function for a user to follow another user
    public shared({caller}) func follow(user: Vertex): async () {
        // Check if Root Feed Canister is anonymous
        assert(not Principal.isAnonymous(rootFeedCanister));
        
        // Create a RootFeedActor instance based on Root Feed Canister
        let rootFeedActor: RootFeedActor = actor(Principal.toText(rootFeedCanister));
        
        // Iterate through all user Feed Canisters in Root Feed Actor
        for((_, _feedCanister) in (await rootFeedActor.getAllUserFeedCanister()).vals()) {
            
            // Create a FeedActor instance based on Feed Canister
            let feedActor: FeedActor = actor(Principal.toText(_feedCanister));
            
            // Ignore the return value when calling followers, the list of followers is in the Feed Canister
            ignore feedActor.updateFollowers(Array.append(
                graph.getReverseAdjacent(user),
                [caller]
            ));
        };
        // Add an edge in the graph, indicating that the user is following another user
        graph.addEdge(caller, user);
    };

    // Get the user's following list
    public query({caller}) func getFollowingList(user: Vertex): async [Vertex] {
        graph.getForwardAdjacent(user)
    };

    // Get the followers list
    public query({caller}) func

 getFollowersList(user: Vertex): async [Vertex] {
        graph.getReverseAdjacent(user)
    };

// ==================== Profiles ====================

    // Define type aliases
    type NewProfile = Types.NewProfile;
    type Profile = Types.Profile;
    type UserId = Types.UserId;

    // Create a database instance to store user profiles
    var directory: Database.Directory = Database.Directory();

    // Create a user profile
    public shared({caller}) func createProfile(profile: NewProfile): async () {
        directory.createOne(caller, profile);
    };

    // Update a user's profile
    public shared({caller}) func updateProfile(profile: NewProfile): async () {
        directory.updateOne(caller, profile);
    };

    // Get a user's profile based on user ID
    public query func getProfile(userId: UserId): async ?Profile {
        directory.findOne(userId)
    };

    // Search for user profiles based on a search term
    public query func searchProfile(term: Text): async [Profile] {
        directory.findBy(term)
    };

}

Now, the User module is complete with basic social network functionalities. It maintains relationships between users using a directed graph, stores and queries user profiles using a database, and updates user activities through the Root Feed Canister.

Next, we'll move on to building the Post module.