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;
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;
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
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
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
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
/**
* @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
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
...
"build": {
"extraResources": "main/api"
},
...
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";
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;
}
Result
This is the first screen when the program starts up.
You should switch the button to turn on the server.
63261 is a port of the server. You can see swagger
in http://localhost:63261/api-docs.
I made a question in swagger
and it can be seen in the app.
I also made a test client.
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!
Top comments (2)
Nice!
Thank you!