Anonymous Realtime Question(Chat) App with Nextron

lico

SeongKuk Han

Posted on August 8, 2022

Anonymous Realtime Question(Chat) App with Nextron

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

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;

Enter fullscreen mode Exit fullscreen mode

Two Components

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;
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

index.css

* {
  font-family: "Noto Sans", sans-serif;
}

#__next {
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

API Server & IPC

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

server.ts

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);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

questions.ts

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;
Enter fullscreen mode Exit fullscreen mode

schemas/question.ts

/**
 * @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 {};
Enter fullscreen mode Exit fullscreen mode

ipc.ts

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 });
  }
};
Enter fullscreen mode Exit fullscreen mode

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

  ...
  "build": {
    "extraResources": "main/api"
  },
  ...
Enter fullscreen mode Exit fullscreen mode

Types and Constants

electron and next share types and constants.

constants.ts

export const TOGGLE_EVENT_REQ = "server:toggle-req";
export const TOGGLE_EVENT_RES = "server:toggle-res";

export const SEND_QUESTION = "SEND_QUESTION";
Enter fullscreen mode Exit fullscreen mode

types.ts

import { Express } from "express";
import { Server } from "http";

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

export interface Question {
  nickname: string;
  question: string;
}
Enter fullscreen mode Exit fullscreen mode

Result

first screen

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

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

swagger

I made a question in swagger and it can be seen in the app.

sending a question in swagger

app displays the question

I also made a test client.

text client entry page

creating a question in the test client

the app displays the question


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

💖 💪 🙅 🚩
lico
SeongKuk Han

Posted on August 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related