Анонимное приложение для вопросов (чат) в реальном времени с Nextron
В моей компании мы собрались, и раз в месяц генеральный директор выступает перед нами с речью. ‘Town Hall Meeting’.
В конце речи генеральный директор ждет, пока мы зададим вопросы. Он сказал, что это хороший способ общения с сотрудниками, и это поможет компании. Но не так-то просто задать вопрос перед полным залом коллег в комнате. Мне пришлось подумать, можно ли задать ему мой вопрос. Я не хочу выглядеть в компании как дурачок.
После совещания мне пришла в голову идея. «Что, если мы сделаем вопрос на мобильном, и он не сможет понять, кто его задает?». Он будет получать от нас честные вопросы, а мы свободно спрашивать у анонимов.
Хотя я не уверен, что это действительно полезно, я решил реализовать эту идею.
Я собираюсь использовать nextron
в своем проекте. nextron
помогает нам легко использовать electron
и next
вместе. Это как create-react-app
.
Структура файла проекта (src)
- основной
- API
- схемы
- вопрос.ts
- questions.ts
- server.ts
- помощники (по умолчанию)
- background.ts (по умолчанию и отредактированный)
- рендерер
- компоненты
- Header.tsx
- вопрос.tsx
- страницы
- _app.tsx (по умолчанию)
- _document.tsx
- index.css (по умолчанию и отредактированный)
- main.tsx (по умолчанию и отредактированный)
- акции
- константы.ts
- типы.ts
Веб-клиент & IPC
nextron
предоставляет примеры переменных, чтобы я мог настроить проект с typescript
и antd
из коробки.
Есть одна страница и два компонента.
Страница
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;
Два компонента
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;
Бизнес-логика находится в компоненте страницы.
В Header
есть переключатель для включения или выключения сервера API и порт сервера.
И настройка шрифта.
_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
Хотя есть один API, я создал swagger
для тестирования.
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
не отображал api docs после сборки. Потому что производственное приложение не могло получить доступ к исходным файлам. Для этого я добавил исходные файлы сервера как дополнительные ресурсы, для этого мне пришлось добавить опцию build.extraResources
в package.json
.
package.json
...
"build": {
"extraResources": "main/api"
},
...
Типы и константы
electron
и next
имеют общие типы и константы.
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;
}
Результат
Это первый экран при запуске программы.
Вы должны переключить кнопку для включения сервера.
63261 — это порт сервера. Вы можете увидеть swagger
в http://localhost:63261/api-docs.
Я сделал вопрос в swagger
и его можно увидеть в приложении.
Я также сделал тестовый клиент.
Заключение
Это стоило того, чтобы попробовать, даже если это не полный результат. Это заставило меня подумать: «Мои идеи могут стать реальностью».
В любом случае, я надеюсь, что это кому-то поможет.
Счастливого кодинга!
Github
- anonymous-question
- anonymous-question-test-client