Build your own personal chat app

Hi frens 👋🏾 Today we are going to build a simple chat application for you, your friends, your mutuals, that is fully peer to peer and encrypted. We’ll start by unpacking the technical terms, but feel free to skip around to the sections below that are most useful for you.

Overview

  • Why is it useful to build your own personal chat app? Being able to have private conversations with your friends means that you can safely share information like your location or major life events without having to worry about a company or another person using your info for their own gain.


    You can easily build your own personal chat app for private conversations between you and anyone you invite to use it. By making the app peer to peer, you are keeping the app’s users in control of their data. By making it encrypted, you ensure the data can only be read by the people you send it to.

  • A peer to peer app is one where every user of the app has the ability to send data to any other user without needing to go through a middle person.

    For most apps, the “middle person” is a company. The company that builds the app collects data from one user and sends it to the others. This gives the company full control over the data and the way it is used.

    A peer to peer app removes the need for a middle person, giving the users of the app full control over their data and their experience.

  • Encryption is the mathematical process of hiding information so that only your intended recipient can view it.
    Generally it works like this: Two people, Asia and Beau, each have two digital keys which are represented by letters and numbers. Asia uses one of Beau’s keys to encrypt a message. Once encrypted no one can understand what the message says.

    But Beau can use her other key to decrypt the message and then she is able to read the message. If anyone else had Beau’s key they would be able to decrypt and read it too.

Not a coder? Not a problem!

Create and manage your own app without writing code by signing up here.

 

Tutorial

Build a chat app from scratch

Free and open source

Here’s the full source code to the application that we will build in this tutorial. Give it a star ⭐️ to come back to it later!

Want help? 📞 Get support!

If you have questions, need help getting unblocked, or just want some technical advice... you can jump on a quick call with our team! (Members can always message us in Discord).

This tutorial is for macOS and Ubuntu. DM us to request this tutorial for a different operating system.

1. MacOS only: Install Xcode Command Line Tools

xcode-select --install

2. Install NVM and Node 16

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash
# open a new terminal
nvm install 16

3. Clone the Electron React Boilerplate and install its dependencies

git clone --depth=1 \
  [https://github.com/electron-react-boilerplate/electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) \
  personal-chat
cd personal-chat
npm install

4. Install native dependencies (outside of webpack)

cd personal-chat/release/app
npm install b4a debounceify graceful-goodbye hyperswarm sqlite sqlite3

5. Setup a database to store the user’s peer identity

// src/main/main.ts
import sqlite3 from "sqlite3";

sqlite3.verbose();

let db: any;

async function createTableIfNotExists() {
  const createTableQuery = `CREATE TABLE IF NOT EXISTS account (seed TEXT)`;
  await db.run(createTableQuery, []);
}

const createWindow = async () => {
// Open the sqlite database file.
open({
    filename: "/tmp/database.db",
    driver: sqlite3.Database,
  })
    .then(async (database: any) => {
      db = database;
      await createTableIfNotExists();
    })
    .catch(console.error);
// ...
}

6. Get a seed for the peer identity

We’ll first check if there is already a seed for the peer identity. If so, we’ll use it. If not, we’ll create a new one and store it in the database.

import crypto from "hypercore-crypto";

async function createSeedIfNotExists(): Promise<string> {
  const row: any = await db.get(`SELECT * FROM account LIMIT 1`);
  if (!row) {
    const seed = crypto.randomBytes(32).toString("hex");
    await db.run("INSERT INTO account VALUES (?)", seed);
    return seed;
  }
  return row.seed;
}

In the callback for opening the db file, we’ll get the seed.

import { open } from "sqlite";

open({
    filename: "/tmp/database.db",
    driver: sqlite3.Database,
  })
    .then(async (database: any) => {
      db = database;
      await createTableIfNotExists();
      const seed = await createSeedIfNotExists();
      console.log("seed", seed);
    })
    .catch(console.error);

Now we can use the seed when we set up our peer so it has a persistent identity. The peer setup will be in a function that handles all the chat functionality.

import Hyperswarm from "hyperswarm";
import goodbye from "graceful-goodbye";
import b4a from "b4a";

function startChatting(window: BrowserWindow, seed: string) {
  const swarm = new Hyperswarm({ seed: b4a.from(seed, "hex"), maxPeers: 31 });
  const publicKey = b4a.toString(swarm.keyPair.publicKey, "hex");
  goodbye(() => swarm.destroy());
}

7. Discover other peers

Inside of the startChatting function, connect to a swarm topic to discover other peers.

const topic = createHash("sha256").update("personal-chat", "utf-8").digest();
      const discovery = swarm.join(topic, { client: true, server: true });
      // The flushed promise will resolve when the topic has been fully announced to the DHT
      discovery
        .flushed()
        .then(() => {
          console.log("joined topic:", topic.toString("hex"));
        })
        .catch(console.error);

8. Handle incoming messages

Just below that handle all incoming messages. We’ll start by just logging them to the console.

// Keep track of all connections and console.log incoming data
  const conns: any = [];
  swarm.on("connection", (conn: any) => {
    const name = b4a.toString(conn.remotePublicKey, "hex");
    console.log("* got a connection from:", name, "*");
    conns.push(conn);
    conn.once("close", () => conns.splice(conns.indexOf(conn), 1));
    conn.on("data", (data: Buffer) => {
      const message = b4a.toString(data, "utf-8");
      console.log(message);
    });
    conn.on("error", console.error);
  });

9. Hanlde outgoing messages

Now we want to handle outgoing messages. For that we’ll need some UI. Create a file called Chat.tsx under the renderer directory.

// renderer/Chat.tsx
import Button from "react-bootstrap/Button";
import Form from "react-bootstrap/Form";
import "./App.css";
import { useEffect, useState } from "react";
import Alert from "react-bootstrap/Alert";
import Stack from "react-bootstrap/Stack";
import Container from "react-bootstrap/Container";

export default function Chat() {
  const [inputValue, setInputValue] = useState("");

  const handleChange = (e: any) => {
    setInputValue(e.target.value);
  };

  const handleSubmit = (e: any) => {
    e.preventDefault();
    console.log(inputValue);
    setInputValue("");
  };

  return (
    <>
      <Stack gap={3}>
        <Form onSubmit={handleSubmit}>
          <Stack direction="horizontal" gap={3}>
            <Form.Control
              className="me-auto"
              type="text"
              value={inputValue}
              onChange={handleChange}
              placeholder="say something!"
            />
            <Button variant="primary" type="submit">
              send
            </Button>
          </Stack>
        </Form>
      </Stack>
    </>
  );
}

Add the Chat component to the main UI. Run npm start and you should see your UI.

// renderer/App.tsx
import { MemoryRouter as Router, Routes, Route } from "react-router-dom";
import "./App.css";
import Chat from "./Chat";
import "bootstrap/dist/css/bootstrap.min.css";

function Main() {
  return (
    <div>
      <Chat />
    </div>
  );
}

export default function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Main />} />
      </Routes>
    </Router>
  );
}

Next we need to send the form input from React to our peers. We’ll add a function and call it when the user submits the form.

// renderer/Chat.tsx
function send(message: string) {
  window.electron.ipcRenderer.sendMessage("chat-out", ["message", message]);
}

// Inside the Chat function
// ...
const handleSubmit = (e: any) => {
    e.preventDefault();
    // Call your function with the input value
    send(inputValue);
    setInputValue("");
  };
// ...

We must define a channel upon which React and Electron will communicate.

// main/preload.ts
export type Channels =
  | "ipc-example"
  | "chat-in"
  | "chat-out";

Then we can forward the messages that come through the channel from React to the peers.

// main/main.ts

  // Add below to the startChatting function
  ipcMain.on("chat-out", async (event, args) => {
    if (args[0] === "message") {
      conns.forEach((conn: any) => {
        const message = b4a.from(args[1], "utf-8");
        conn.write(message);
      });
    }
    event.reply("chat-out", `message sent`);
  });
// ...

10. Forward incoming messages

Before we’re done, let’s also make sure we can get any incoming messages in our React UI.

// main/main.ts
swarm.on("connection", (conn: any) => {
    const name = b4a.toString(conn.remotePublicKey, "hex");
    console.log("* got a connection from:", name, "*");
    conns.push(conn);
    conn.once("close", () => conns.splice(conns.indexOf(conn), 1));
    conn.on("data", (data: Buffer) => {
      const message = b4a.toString(data, "utf-8");
      console.log(message);
      const id = createHash("md5")
        .update(`n:${name}:m:${message}:t:${Date.now()}`)
        .digest("hex");
      window.webContents.send(
        "chat-in",
        JSON.stringify({ id, from: name, message })
      );
    });
    conn.on("error", console.error);
  });

11. Store all messages in React

Update the Chat component to store all messages. Both the outgoing and the incoming.

// renderer/Chat.tsx

const cache: Array<any> = [];
function addToCache(item: any) {
  if (cache.length > 29) {
    cache.shift();
  }
  cache.push(item);
}

export default function Chat() {
  const [inputValue, setInputValue] = useState("");
  const [messages, setMessages] = useState<any[]>(cache);

  useEffect(() => {
    const removeListener = window.electron.ipcRenderer.on(
      "chat-in",
      (data: any) => {
        const nextMessage = JSON.parse(data);
        addToCache(nextMessage);
        setMessages([...cache]);
      }
    );
    return () => {
      if (removeListener) removeListener();
    };
  }, []);

  // ...

  const handleSubmit = (e: any) => {
    e.preventDefault();
    // Call your function with the input value
    send(inputValue);
    addToCache({ id: String(Math.random()), from: "me", message: inputValue });
    setMessages([...cache]);
    setInputValue("");
  };

12. Display all messages

Let’s finish up with some UI to display these messages.

function renderMessage(message: Message) {
  if (message.from === "me") {
    return (
      <Stack key={message.id} direction="horizontal" gap={3}>
        <Container fluid>
          <Alert key={message.id} variant="primary" className="me-auto">
            {message.message}
          </Alert>
        </Container>
        <div>me</div>
      </Stack>
    );
  }

  return (
    <Stack key={message.id} direction="horizontal" gap={3}>
      <div>{message.from.substring(0, 5)}</div>
      <Container fluid>
        <Alert key={message.id} variant="secondary" className="me-auto">
          {message.message}
        </Alert>
      </Container>
    </Stack>
  );
}

export default function Chat() {
  // ...

  return (
    <>
      <Stack
        style={{ height: "70vh", overflow: "scroll", justifyContent: "end" }}
      >
        {messages.map(renderMessage)}
      </Stack>
        <hr />
      <Stack gap={3}>
// ...

13. Run the app!

cd personal-chat
npm run start
 

Challenge 🤔

Start a remote server. (It has to be on a network other than the one your app is running on. The easiest way to achieve this using a Digital Ocean droplet or AWS EC2.) Clone this tutorial repo https://github.com/futureproofso/tv and run the test file by entering node test/peer.mjs in the command line. Connect to the personal-chat topic and try to send and receive messages from both the remote server and your Electron app.

Let us know if you are able to complete the challenge!
Previous
Previous

Own your social media content