Anonymous Realtime Question(Chat) App with Nextron
Tags: electron, react, tutorial, node
Published: 2022-08-08 08:24:00

> Anonymous Realtime Question(Chat) App with Nextron

We are gathered and the CEO gives us a speech once a month in my company. 'Town Hall Meeting'.

At the end of the speech, the CEO waits for us to ask questions. He said that's a good way to communicate with the employees and it will help the company. But it's not easy to ask a question in front of full of coworkers in the room. I had to think about whether my question is okay to say to him. I don't wanna be dorky at the company.

After the meeting, I come up with an idea. "What if we can make a question on mobile and he's not able to figure out who asks?". He will get honest questions from us, and we ask freely with the anonymous.
Although I'm not sure if it's actually useful tho, I decided to implement this idea

I'm going to use `nextron` in my project. `nextron` helps us to use easily `electron` with `next` together. It's like a `create-react-app`.

---

## Project File Structure (src)

- main
  - API
    - schemas
      - question.ts
    - questions.ts
    - server.ts
    - helpers (default)
    - background.ts (default and edited)
- renderer
  - components
    - Header.tsx
    - Question.tsx
  - pages
    - _app.tsx (default)
    - _document.tsx
    - index.css (default and edited)
    - main.tsx (default and edited)
- shares
  - constants.ts
  - types.ts

---

## Web Client & IPC
`nextron` provides variables examples so that I could set up the project with `typescript` and `antd` out of the box.
There are one page and two components.

Page
```typescript
import React, { useEffect, useRef, useState } from "react";
import { ipcRenderer } from "electron";
import styled from "@emotion/styled";
import Head from "next/head";

import {
  SEND_QUESTION,
  TOGGLE_EVENT_REQ,
  TOGGLE_EVENT_RES,
} from "../../shares/constants";
import { Question as TQuestion } from "../../shares/types";
import Header from "../components/Header";
import Question from "../components/Question";

const Container = styled.div`
  height: 100%;
`;

const Content = styled.div`
  background-color: #ecf0f1;
  height: calc(100% - 64px);
`;

const QuestionContainer = styled.div`
  box-sizing: border-box;

  padding: 24px;
  margin-top: auto;
  max-height: 100%;
  overflow: auto;
`;

function Main() {
  const [working, setWorking] = useState(false);
  const [port, setPort] = useState("");
  const [serverOn, setServerOn] = useState(false);
  const [questions, setQuestions] = useState<TQuestion[]>([]);
  const questionContainerRef = useRef<HTMLDivElement>(null);

  const handleServerToggle = () => {
    setPort("");
    ipcRenderer.send(TOGGLE_EVENT_REQ, !serverOn);
    setWorking(true);
  };

  const scrollToBottom = () => {
    if (!questionContainerRef.current) return;

    questionContainerRef.current.scrollTo({
      top: questionContainerRef.current.scrollHeight,
      behavior: "smooth",
    });
  };

  useEffect(() => {
    ipcRenderer.on(
      TOGGLE_EVENT_RES,
      (_, { result, port }: { result: boolean; port?: string }) => {
        if (!result) return;
        if (port) setPort(port);

        setServerOn((prev) => !prev);
        setWorking(false);
      }
    );

    ipcRenderer.on(SEND_QUESTION, (_, question: TQuestion) => {
      setQuestions((prevQuestions) => prevQuestions.concat(question));
    });
  }, []);

  useEffect(() => {
    scrollToBottom();
  }, [questions]);

  return (
    <Container>
      <Head>
        <title>Anonymous Question</title>
      </Head>
      <Header
        port={port}
        serverOn={serverOn}
        onServerToggle={handleServerToggle}
        serverOnDisabled={working}
      />
      <Content>
        <QuestionContainer ref={questionContainerRef}>
          {questions.map((q, qIdx) => (
            <Question key={qIdx} {...q} />
          ))}
        </QuestionContainer>
      </Content>
    </Container>
  );
}

export default Main;

```

Two Components
```typescript
import { Avatar, Typography } from "antd";
import styled from "@emotion/styled";

interface QuestionProps {
  nickname: string;
  question: string;
}

let nextRandomColorIdx = 0;
const randomColors = [
  "#f56a00",
  "#e17055",
  "#0984e3",
  "#6c5ce7",
  "#fdcb6e",
  "#00b894",
];

const nicknameColors: { [key: string]: string } = {};

const getNicknameColor = (nickname: string) => {
  if (nicknameColors[nickname]) return nicknameColors[nickname];

  nicknameColors[nickname] = randomColors[nextRandomColorIdx];
  nextRandomColorIdx = (nextRandomColorIdx + 1) % randomColors.length;

  return nicknameColors[nickname];
};

const Container = styled.div`
  &:hover {
    transform: scale(1.05);
  }

  padding: 8px;
  border-bottom: 1px solid #ccc;
  transition: all 0.2s;
  display: flex;
  align-items: center;
  column-gap: 8px;

  > *:first-of-type {
    min-width: 48px;
  }
`;

const Question = ({ nickname, question }: QuestionProps) => {
  return (
    <Container>
      <Avatar
        size={48}
        style={{ backgroundColor: getNicknameColor(nickname), marginRight: 8 }}
      >
        {nickname}
      </Avatar>
      <Typography.Text>{question}</Typography.Text>
    </Container>
  );
};
export default Question;
```

```typescript
import { Switch, Typography, Layout } from "antd";

export interface HeaderProps {
  serverOn?: boolean;
  onServerToggle?: VoidFunction;
  serverOnDisabled?: boolean;
  port: string;
}

const Header = ({
  serverOn,
  onServerToggle,
  serverOnDisabled,
  port,
}: HeaderProps) => {
  return (
    <Layout.Header style={{ display: "flex", alignItems: "center" }}>
      <Typography.Text style={{ color: "white", marginRight: 12 }}>
        (:{port}) Server Status:
      </Typography.Text>
      <Switch
        checkedChildren="ON"
        unCheckedChildren="OFF"
        disabled={serverOnDisabled}
        checked={serverOn}
        onChange={onServerToggle}
      />
    </Layout.Header>
  );
};

export default Header;
```

Business logic is in the page component.  
`Question` generates a color depending on the first letter of `nickname`.
There is a switch for turning on or off the API server and the server's port in `Header`.

And a font setting.

_document.tsx
```typescript
import { Html, Head, Main, NextScript } from "next/document";

export default function Document() {
  return (
    <Html>
      <Head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link
          rel="preconnect"
          href="https://fonts.gstatic.com"
          crossOrigin="true"
        />
        <link
          href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
          rel="stylesheet"
        ></link>
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
```

index.css
```
* {
  font-family: "Noto Sans", sans-serif;
}

#__next {
  height: 100%;
}
```

---

## API Server & IPC

there was one API though, I set up `swagger` for testing purpose.

server.ts
```typescript
import { ipcMain } from "electron";
import path from "path";
import { networkInterfaces } from "os";
import express, { Express } from "express";
import { default as dotenv } from "dotenv";
import cors from "cors";
import swaggerUi from "swagger-ui-express";
import swaggerJsdoc from "swagger-jsdoc";

import { ServerInfo } from "../../shares/types";
import { TOGGLE_EVENT_REQ, TOGGLE_EVENT_RES } from "../../shares/constants";
import questionApi from "./questions";

const isProd: boolean = process.env.NODE_ENV === "production";
let serverInfo: ServerInfo | undefined;

dotenv.config();

const nets = networkInterfaces();
const addressList: string[] = [];

for (const value of Object.values(nets)) {
  for (const net of value) {
    if (net.family === "IPv4" && !net.internal) {
      addressList.push(net.address);
      break;
    }
  }
}

/** Swagger */
const addSwaggerToApp = (app: Express, port: string) => {
  const options = {
    definition: {
      openapi: "3.0.0",
      info: {
        title: "Anonymous Question API with Swagger",
        version: "0.0.1",
        description: "Anonymous Question API Server",
        license: {
          name: "MIT",
          url: "https://spdx.org/licenses/MIT.html",
        },
        contact: {
          name: "lico",
          url: "https://www.linkedin.com/in/seongkuk-han-49022419b/",
          email: "hsk.coder@gmail.com",
        },
      },
      servers: addressList.map((address) => ({
        url: `http://${address}:${port}`,
      })),
    },
    apis: isProd ? [
      path.join(process.resourcesPath, "main/api/questions.ts"),
      path.join(process.resourcesPath, "main/api/schemas/*.ts"),
    ]: ["./main/api/questions.ts", "./main/api/schemas/*.ts"],
  };

  const specs = swaggerJsdoc(options);

  app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
};

/** Events */

ipcMain.on(TOGGLE_EVENT_REQ, async (event, on) => {
  if (on && serverInfo !== undefined) {
    event.reply(TOGGLE_EVENT_RES, {
      result: false,
      message: `It's already on.`,
    });
    return;
  } else if (!on && serverInfo === undefined) {
    event.reply(TOGGLE_EVENT_RES, {
      result: true,
      message: `The server isn't running.`,
    });
    return;
  }

  let port: string | undefined;

  try {
    if (on) {
      port = await startServer();
    } else {
      await stopServer();
    }

    event.reply(TOGGLE_EVENT_RES, { result: true, message: "Succeed.", port });
  } catch (e) {
    console.error(e);
    event.reply(TOGGLE_EVENT_RES, {
      result: false,
      message: `Something went wrong.`,
    });
  }
});

/** Server */

const configureServer = (app: Express) => {
  app.use(express.json());
  app.use(cors());

  app.use("/api", questionApi);
};

export const startServer = (): Promise<string> => {
  return new Promise((resolve, reject) => {
    const app = express();
    const port = process.env.SERVER_PORT;

    configureServer(app);

    const server = app
      .listen(undefined, () => {
        const port = (server.address() as { port: number }).port.toString();
        console.log(`Server has been started on ${port}.`);
        addSwaggerToApp(app, port);
        resolve(port);
      })
      .on("error", (err) => {
        reject(err);
      });

    serverInfo = {
      app,
      port,
      server,
    };
  });
};

export const stopServer = (): Promise<void> => {
  return new Promise((resolve, reject) => {
    try {
      if (!serverInfo) throw new Error("There is no server information.");

      serverInfo.server.close(() => {
        console.log("Server has been stopped.");
        serverInfo = undefined;
        resolve();
      });
    } catch (e) {
      console.error(e);
      reject(e);
    }
  });
};
```

questions.ts
```typescript
import express, { Request } from "express";
import { Question } from "../../shares/types";
import { sendQuestionMessage } from "./ipc";

const router = express.Router();

interface QuestionParams extends Request {
  body: Question;
}

/**
 * @swagger
 * tags:
 *  name: Questions
 *  description: API to manager questions.
 *
 * @swagger
 * /api/questions:
 *  post:
 *    summary: Creates a new question
 *    tags: [Questions]
 *    requestBody:
 *      required: true
 *      content:
 *        application/json:
 *          schema:
 *            $ref:  '#/components/schemas/Question'
 *    responses:
 *      "200":
 *        description: Succeed to request a question
 *        content:
 *          application/json:
 *            schema:
 *              $ref: '#/components/schemas/Question'
 */
router.post("/questions", (req: QuestionParams, res) => {
  if (!req.body.nickname && !req.body.question) {
    return res.status(400).send("Bad Request");
  }

  const question = req.body.question.trim();
  const nickname = req.body.nickname.trim();

  if (nickname.length >= 2) {
    return res
      .status(400)
      .send("Length of the nickname must be less than or equal to 2.");
  } else if (question.length >= 100) {
    return res
      .status(400)
      .send("Length of the quesztion must be less than or equal to 100.");
  }

  sendQuestionMessage(nickname, question);

  return res.json({
    question,
    nickname,
  });
});

export default router;
```

schemas/question.ts
```typescript
/**
 * @swagger
 *  components:
 *    schemas:
 *      Question:
 *        type: object
 *        required:
 *          - nickname
 *          - question
 *        properties:
 *          nickname:
 *            type: string;
 *            minLength: 1
 *            maxLength: 1
 *          question:
 *            type: string;
 *            minLength: 1
 *            maxLength: 100
 *        example:
 *          nickname: S
 *          question: What is your name?
 */

export {};
```

ipc.ts
```typescript
import { webContents } from "electron";
import { SEND_QUESTION } from "../../shares/constants";

export const sendQuestionMessage = (nickname: string, question: string) => {
  const contents = webContents.getAllWebContents();

  for (const content of contents) {
    content.send(SEND_QUESTION, { nickname, question });
  }
};
```

`swagger` didn't display api docs after building. Because the production app couldn't access source files. For that I added server source files as extra resources, for that I had to add an option `build.extraResources` in `package.json`.

package.json
```json
  ...
  "build": {
    "extraResources": "main/api"
  },
  ...
```

---

## Types and Constants

`electron` and `next` share types and constants.

constants.ts
```typescript
export const TOGGLE_EVENT_REQ = "server:toggle-req";
export const TOGGLE_EVENT_RES = "server:toggle-res";

export const SEND_QUESTION = "SEND_QUESTION";
```

types.ts
```typescript
import { Express } from "express";
import { Server } from "http";

export interface ServerInfo {
  port: string;
  app: Express;
  server: Server;
}

export interface Question {
  nickname: string;
  question: string;
}
```

---

## Result

![first screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/736cbwodlq2h6at3j3d5.png)
 
This is the first screen when the program starts up.
You should switch the button to turn on the server.

![turned on the api server](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mru5c577kaoi9s2vlt0p.png)
 

**63261** is a port of the server. You can see `swagger` in **http://localhost:63261/api-docs**.

![swagger](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cwj9tdw9b9qgyxt3w0c1.png)
 
I made a question in `swagger` and it can be seen in the app.

![sending a question in swagger](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/07e2mweun53dv0zwyb8p.png)
 
![app displays the question](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o5ku8s72ntnmmqruds6w.png)

I also made a [test client](https://hsk-kr.github.io/anonymous-question-test-client/).

![text client entry page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4vlybv7s8i78fhhta6f9.png)

![creating a question in the test client](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wzplrgoqfktjtx8eaulp.png)

![the app displays the question](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3o0bu8pcgo04nydayu7l.png)

---

## Conclusion

It was worth it trying even if that is not complete. It's made me think 'My ideas can be reality.'.

Anyway, I hope it will help someone.

Happy Coding!

### Github
- [anonymous-question](https://github.com/hsk-kr/anonymous-question)  
- [anonymous-question-test-client](https://github.com/hsk-kr/anonymous-question-test-client)