Feat: メッセージの送受信 / New: ユーザーのiconプロパティ / New: logエンティティ・リポジトリ / Chg: コミュニティリポジトリのスキーマのiconをoptionalに / Del: 不要なimport / Fix: Vue起動前のindex.htmlの背景色をVueと同期 / Enhance: Service Workerを改善 / Fix: 最初に開いたページが動作しない問題 / Feat: 上部の通知モーダル / Feat: 閉じることができないエラーのモーダルに再読み込みボタンを追加 / Fix: はみ出す挙動などのCSSを修正

This commit is contained in:
2026-05-31 13:54:55 +09:00
parent beb0e25ad9
commit cbf18aec8f
37 changed files with 1174 additions and 83 deletions
+1 -1
View File
@@ -22,12 +22,12 @@
"@mikro-orm/postgresql": "^6.6.7",
"@mikro-orm/reflection": "^6.6.7",
"@types/web-push": "^3.6.4",
"lynqchat-js": "workspace:*",
"argon2": "^0.44.0",
"cross-env": "^10.1.0",
"fastify": "^5.7.4",
"fastify-plugin": "^5.1.0",
"fs": "0.0.1-security",
"lynqchat-js": "workspace:*",
"os": "^0.1.2",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
+4
View File
@@ -0,0 +1,4 @@
export const Pick = <T extends Object>(object: T, keys: (keyof T)[]) =>
Object.entries(object)
.filter(([key]) => keys.includes(key as keyof T))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
@@ -0,0 +1,37 @@
import { Entity, EntityRepositoryType, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { LogRepository } from "@/modules/repositories/Log";
import generateUniqueId from "@/lib/id";
import { UserEntity } from "@/modules/entities/User";
@Entity({
tableName: "log",
repository: () => LogRepository,
})
export class LogEntity {
[OptionalProps]?: "id" | "createdBy" | "createdAt";
[EntityRepositoryType]?: LogRepository;
@PrimaryKey({
type: "string",
length: 10,
onCreate: () => generateUniqueId(),
})
id!: string;
@Property({
type: "string",
length: 4096
})
text!: string;
@ManyToOne(() => UserEntity, {
nullable: true,
})
createdBy?: UserEntity;
@Property({
type: "datetime",
onCreate: () => new Date(),
})
createdAt!: Date;
}
@@ -1,12 +1,15 @@
import generateUniqueId from "@/lib/id";
import { Entity, Index, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { Entity, EntityRepositoryType, ManyToOne, OptionalProps, PrimaryKey, Property } from "@mikro-orm/core";
import { UserEntity } from "@/modules/entities/User";
import { ChannelEntity } from "@/modules/entities/Channel";
import { MessageRepository } from "@/modules/repositories/Message";
@Entity({
tableName: "message",
repository: () => MessageRepository,
})
export class MessageEntity {
[EntityRepositoryType]?: MessageRepository;
[OptionalProps]?: "id" | "createdAt";
@PrimaryKey({
@@ -30,6 +30,12 @@ export class UserEntity {
})
username!: string;
@Property({
type: "string",
nullable: true,
})
icon?: string;
@Property({
type: "string",
length: 4096,
@@ -9,7 +9,7 @@ export class CommunityRepository extends EntityRepository<CommunityEntity> {
name: z.string().trim().min(1).max(20),
description: z.string().trim().min(1).max(4096),
userid: UserRepository.schema.shape.userid,
icon: z.string().url(),
icon: z.string().url().optional(),
});
async listCommunity(limit: number = 20, sinceData?: string) {
@@ -0,0 +1,17 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import type { LogEntity } from "@/modules/entities/Log";
import z from "zod/v3";
export class LogRepository extends EntityRepository<LogEntity> {
public static schema = z.string().min(1).max(4096);
async insertLog(text: string, userid?: string) {
const log = this.create({
text,
createdBy: userid,
});
await this.em.persist(log).flush();
return log.id;
}
}
@@ -0,0 +1,63 @@
import { EntityRepository } from "@mikro-orm/postgresql";
import type { MessageEntity } from "@/modules/entities/Message";
import z from "zod/v3";
import { ErrorBase } from "@/errors";
export class MessageRepository extends EntityRepository<MessageEntity> {
public static schema = z.object({
message: z.string().trim().min(0).max(4096),
channel: z.string().length(10),
userid: z.string().length(10),
});
async sendMessage(messageText: string, channel: string, user: string) {
const message = this.create({
message: messageText,
channel,
createdBy: user,
});
await this.em.persist(message).flush();
return message.id;
}
async deleteMessage(id: string) {
const message = this.getReference(id);
await this.em.remove(message).flush();
}
async listMessage(channel: string, limit: number = 20, untilData?: string) {
let until = untilData ?? new Date();
if (
untilData &&
isNaN(new Date(untilData).getTime())
) {
const itMessage = await this.findOne({ id: untilData });
if (!itMessage) {
return ErrorBase({
bad: "client",
code: "message_not_found",
message: "対象のメッセージが見つかりませんでした。",
});
}
until = itMessage.createdAt;
}
const findResult = await this.find({
channel,
createdAt: {
$lt: until,
},
}, {
orderBy: {
createdAt: "DESC",
},
limit: limit,
});
return findResult ?? [];
}
}
@@ -8,6 +8,7 @@ export class UserRepository extends EntityRepository<UserEntity> {
public static schema = z.object({
userid: z.string().trim().min(3).max(20),
username: z.string().trim().min(3).max(30),
icon: z.string().url().optional(),
profile: z.string().max(4096).optional(),
email: z.string().trim().min(6).max(254).regex(EmailRegex),
password: z.string().trim().min(8),
@@ -1,7 +1,6 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { CommunityEntity } from "@/modules/entities/Community";
import { UserEntity } from "@/modules/entities/User";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
+5
View File
@@ -5,6 +5,7 @@ import Me from "./me";
import ServerInfo from "./server-info";
import Community from "./community";
import Channel from "./channel";
import Message from "./message";
export default async function Routes(fastify: FastifyInstance) {
await fastify.register(Setup, {
@@ -30,4 +31,8 @@ export default async function Routes(fastify: FastifyInstance) {
await fastify.register(Channel, {
prefix: "/channel",
});
await fastify.register(Message, {
prefix: "/message",
});
}
@@ -0,0 +1,37 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { MessageEntity } from "@/modules/entities/Message";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function MessageDelete(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | message/delete");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
if ("error" in req.token)
return res.code(400).send(req.token);
const result = z.object({
id: z.string().length(10),
}).safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
await messageRepo.deleteMessage(result.data.id);
return res.send({
success: true,
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import MessageList from "./list";
import MessageSend from "./send";
import MessageDelete from "./delete";
export default async function Message(fastify: FastifyInstance) {
await fastify.register(MessageList, {
prefix: "/list",
});
await fastify.register(MessageSend, {
prefix: "/send",
});
await fastify.register(MessageDelete, {
prefix: "/delete",
});
}
@@ -0,0 +1,55 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { Pick } from "@/lib/object";
import { MessageEntity } from "@/modules/entities/Message";
import type { FastifyInstance } from "fastify";
import z from "zod/v3";
export default async function MessageList(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | message/list");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
if ("error" in req.token)
return res.code(400).send(req.token);
const bodySchema = z.object({
limit: z.number().optional(),
until: z.string().optional(),
channel: z.string().length(10),
});
const result = bodySchema.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
const findResult = await messageRepo.listMessage(
result.data.channel,
result.data.limit,
result.data.until,
);
if ("error" in findResult) {
return res.code(400).send(findResult);
}
return res.send({
success: true,
messages: findResult.map(message => ({
...message,
createdBy: Pick(message.createdBy, ["id", "userid", "username", "icon"]),
})),
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
@@ -0,0 +1,39 @@
import { DatabaseError, InputError } from "@/errors";
import Logger from "@/lib/logger";
import { MessageEntity } from "@/modules/entities/Message";
import { MessageRepository } from "@/modules/repositories/Message";
import type { FastifyInstance } from "fastify";
export default async function MessageSend(fastify: FastifyInstance) {
const logger = new Logger("Endpoint | message/send");
fastify.post("/", async (req, res) => {
res.header("Content-Type", "application/json");
res.header("Connection", "close");
if ("error" in req.token)
return res.code(400).send(req.token);
const result = MessageRepository.schema
.omit({ userid: true })
.safeParse(req.body);
if (!result.success) {
return res.code(400).send(InputError(result.error.issues));
}
try {
const messageRepo = fastify.orm.em.getRepository(MessageEntity);
const id = await messageRepo.sendMessage(result.data.message, result.data.channel, req.token.user.id);
return res.send({
success: true,
id,
});
} catch (err) {
logger.error("Database Error:", err);
return res.code(500).send(DatabaseError());
}
});
}
+2 -2
View File
@@ -18,12 +18,12 @@
width: 100vw;
height: 100vh;
font-size: 16px;
background-color: #ffffff;
background-color: #f0f0f0;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #1b1b1b;
background-color: #181818;
}
}
+30 -15
View File
@@ -5,7 +5,9 @@
:size="6"
/>
<div class="modals-container" />
<div class="modals-container">
<div class="top-notice-container" tabindex="-1" />
</div>
<main class="layout">
<div class="left-menu">
@@ -65,6 +67,22 @@
</template>
<style scoped>
.top-notice-container {
transform: translateZ(0);
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
padding-top: 1rem;
gap: 1rem;
width: 100dvw;
height: 100dvh;
pointer-events: none;
background-color: transparent;
}
main.layout {
display: flex;
width: 100dvw;
@@ -92,6 +110,7 @@ main.layout {
position: relative;
z-index: 0;
border-radius: 0.5rem;
border: 1px solid transparent;
width: 3rem;
height: 3rem;
padding: 0.5rem;
@@ -131,7 +150,11 @@ main.layout {
.left-menu a:hover,
.left-menu a.isActive {
background-color: var(--border-color);
background-color: var(--bg-sub-color);
}
.left-menu a.isActive {
border: 1px solid var(--border-color);
}
.content-main {
@@ -140,6 +163,9 @@ main.layout {
border-radius: 0.5rem;
overflow: hidden;
flex-grow: 1;
width: 100%;
display: flex;
flex-direction: column;
}
.content-header {
@@ -160,7 +186,9 @@ main.layout {
padding: 1.25rem;
padding-bottom: 0;
overflow: scroll;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.content-main .route-main.full-route {
@@ -256,19 +284,6 @@ function handleError(event: ErrorEvent | PromiseRejectionEvent) {
onMounted(async () => {
window.addEventListener("error", handleError);
window.addEventListener("unhandledrejection", handleError);
if ("serviceWorker" in navigator) {
const swFile = import.meta.env.MODE === "production"
? "/sw.js"
: "/dev-sw.js?dev-sw";
navigator.serviceWorker.register(swFile, {
type: import.meta.env.MODE === "production"
? "classic"
: "module",
scope: "/",
});
}
});
onBeforeUnmount(() => {
@@ -0,0 +1,187 @@
<template>
<div class="message">
<img
v-if="message.createdBy.icon"
:src="message.createdBy.icon"
/>
<Icon
v-else
icon="material-symbols:account-circle"
/>
<div class="content">
<div>
<span class="username">{{ message.createdBy.username }}</span>
<span class="userid">@{{ message.createdBy.userid }}</span>
<span class="dot"></span>
<span
class="time"
:title="new Date(message.createdAt).toLocaleString()"
>{{ DateParse(message.createdAt) }}</span>
</div>
<span class="main-text" v-html='message.message.replaceAll("\n", "<br>")' />
</div>
<div class="menu">
<Icon
icon="material-symbols:content-copy-rounded"
@click="copyMessage()"
/>
<Icon
icon="material-symbols:delete-rounded"
style="color: #ff0000"
@click="deleteMessage()"
/>
</div>
</div>
</template>
<style scoped>
.message {
position: relative;
display: flex;
width: 100%;
gap: 0.5rem;
box-sizing: border-box;
padding: 1rem;
margin: 0 auto;
border-radius: 1rem;
}
.message:hover {
background-color: var(--bg-sub-color);
}
.message > img,
.message > svg {
width: 3rem;
height: 3rem;
border-radius: 100%;
}
.content {
display: flex;
flex-direction: column;
}
.content > div {
display: flex;
gap: 0.25rem;
}
.username {
font-weight: bold;
}
.userid {
color: var(--text-sub-color);
}
.main-text {
width: 100%;
word-break: break-all;
}
.menu {
display: none;
position: absolute;
inset: 0.5rem 0.5rem auto auto;
gap: 0.25rem;
border-radius: 0.5rem;
background-color: var(--bg-color);
}
.message:hover > .menu {
display: flex;
}
.menu > svg {
width: 2em;
height: 2rem;
padding: 0.25rem;
border-radius: 0.5rem;
margin: auto 0;
cursor: pointer;
}
.menu > svg:hover {
background-color: var(--route-bg-color);
}
</style>
<script lang="ts" setup>
import client from "@/lib/client";
import { createModal, createTopNotice } from "@/lib/modal";
import { DateParse } from "@/lib/parser";
import { Icon } from "@iconify/vue";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import ErrorModal from "@/components/Modal/Error.vue";
import Confirm from "@/components/Modal/Confirm.vue";
const props = defineProps<{
message: Extract<ApiMap["message/list"]["response"], { messages: any }>["messages"][number];
}>();
const emit = defineEmits();
const copyMessage = async () => {
await navigator.clipboard.writeText(props.message.message);
createTopNotice({
props: {
icon: "material-symbols:check-circle-rounded",
iconColor: "var(--success-color)",
message: "コピーしました",
},
});
}
const deleteMessage = async () => {
await new Promise<void>(resolve => {
createModal({
component: Confirm,
onClose: (data: { result: string }) => {
switch (data.result) {
case "yes":
resolve();
break;
default:
return;
}
},
props: {
message: "このメッセージを削除します",
},
});
});
const res = await client.value.request("message/delete", {
id: props.message.id,
});
if (!res.success) {
createModal({
component: ErrorModal,
props: {
error: "メッセージの削除に失敗しました",
canClose: true,
},
});
return;
}
emit("deleteMessage", {
id: props.message.id,
});
createTopNotice({
props: {
icon: "material-symbols:check-circle-rounded",
iconColor: "var(--success-color)",
message: "削除しました",
},
});
}
</script>
@@ -0,0 +1,60 @@
<template>
<div class="modal">
<Icon
icon="line-md:alert"
class="warning-badge"
width="4rem"
/>
<span class="modal-title">よろしいですか</span>
<p>{{ message }}</p>
<div>
<Button
name="はい"
color="accent"
@click='$emit("PleaseClose", { result: "yes" })'
/>
<Button
name="いいえ"
color="default"
@click='$emit("PleaseClose", { result: "no" })'
/>
</div>
</div>
</template>
<style scoped>
.modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.75rem;
}
.modal-title {
font-size: 1.1rem;
font-weight: bold;
}
.warning-badge {
color: var(--warn-color);
}
.modal > div {
display: flex;
gap: 1rem;
}
</style>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import Button from "@/components/Button.vue";
defineProps<{
message: string;
}>();
</script>
@@ -16,6 +16,13 @@
color="accent"
@click='$emit("PleaseClose")'
/>
<Button
v-else
name="再読み込み"
color="accent"
@click="reload()"
/>
</div>
</template>
@@ -46,4 +53,6 @@ defineProps<{
error: any;
canClose: boolean;
}>();
const reload = () => window.location.reload();
</script>
@@ -0,0 +1,75 @@
<template>
<div
class="modal"
:class="fadeOut"
>
<Icon
:icon='icon ?? "material-symbols:info-rounded"'
class="error-badge"
width="4rem"
/>
<span>{{ message }}</span>
</div>
</template>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.modal {
display: flex;
padding: 0.75rem;
gap: 0.25rem;
animation: fadeIn 200ms ease forwards;
}
.modal.fadeOut {
animation: fadeOut 200ms ease forwards;
}
svg {
width: 1.25rem;
height: 1.25rem;
margin: auto 0;
color: v-bind(iconColor);
}
</style>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import { ref } from "vue";
const props = defineProps<{
icon?: string;
iconColor?: string;
timeout?: number;
message: string;
}>();
const iconColor = props.iconColor ?? "var(--text-color)";
const emit = defineEmits();
const fadeOut = ref<string>("");
setTimeout(async () => {
fadeOut.value = "fadeOut";
await new Promise<void>(resolve => setTimeout(resolve, 250));
emit("PleaseClose");
}, props.timeout ?? 1500);
</script>
+2 -1
View File
@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=BIZ+UDPGothic&display=swap");
@import url("https://fonts.googleapis.com/css2?family=BIZ+UDPGothic:wght@400;700&display=swap");
:root {
--bg-color: #f0f0f0;
@@ -8,6 +8,7 @@
--text-sub-color: #595959;
--border-color: #e0e0e0;
--error-color: #ff0000;
--warn-color: #ff9d00;
--success-color: #00ff00;
--accent-color: #3ad0a1;
--accent-in-text-color: #000000;
+41 -22
View File
@@ -1,37 +1,46 @@
import TopNotice from "@/components/Modal/TopNotice.vue";
import { createApp, h, ref, type Component } from "vue";
const layer = ref<number>(0);
const layer = ref<number>(1);
export const createModal = <T extends Component>(data: {
component: T,
isTopNotice?: boolean;
style?: Record<string, string>,
props?: Record<string, ((...args: any[]) => any) | any>,
onClose?: () => void
onClose?: (...args: any) => any
}) => {
layer.value++
const newContainer = document.createElement("div");
for (const [key, value] of Object.entries({
...data.style,
transform: `translateZ(${layer.value})`,
position: "fixed",
inset: "0",
display: "flex",
"justify-content": "center",
"align-items": "center",
width: "100dvw",
height: "100dvh",
"background-color": "#00000080",
})) {
newContainer.style.setProperty(key, value);
}
layer.value++;
const container = document.querySelector(".modals-container")!
.appendChild(newContainer);
let container: HTMLDivElement;
if (!data.isTopNotice) {
const newContainer = document.createElement("div");
for (const [key, value] of Object.entries({
...data.style,
"transform": `translateZ(${layer.value})`,
"position": "fixed",
"inset": "0",
"display": "flex",
"justify-content": "center",
"align-items": "center",
"width": "100dvw",
"height": "100dvh",
"background-color": "#00000080",
})) {
newContainer.style.setProperty(key, value);
}
container = document.querySelector(".modals-container")!
.appendChild(newContainer);
} else {
container = document.querySelector(".top-notice-container")!
.appendChild(document.createElement("div"));
}
let app: ReturnType<typeof createApp>;
const close = () => {
data.onClose?.();
const close = (closeData?: any) => {
data.onClose?.(closeData);
layer.value--;
app.unmount();
container.remove();
@@ -50,3 +59,13 @@ export const createModal = <T extends Component>(data: {
return close;
}
export const createTopNotice = (data?: {
style?: Record<string, string>,
props?: Record<string, ((...args: any[]) => any) | any>,
onClose?: (...args: any) => any
}) => createModal({
...data,
component: TopNotice,
isTopNotice: true,
});
+28
View File
@@ -0,0 +1,28 @@
export function DateParse(date: string | Date, startDate: Date = new Date()) {
const diffMs = startDate.getTime() - new Date(date).getTime();
const diffSec = Math.abs(Math.floor(diffMs / 1000));
const diffMin = Math.abs(Math.floor(diffSec / 60));
const diffHour = Math.abs(Math.floor(diffMin / 60));
const diffDay = Math.abs(Math.floor(diffHour / 24));
const diffMonth = Math.abs(Math.floor(diffDay / 30));
const diffYear = Math.abs(Math.floor(diffMonth / 12));
const diffStr = diffMs < 0
? "後"
: "前";
switch (true) {
case diffSec < 60:
return `${diffSec}${diffStr}`;
case diffMin < 60:
return `${diffMin}${diffStr}`;
case diffHour < 24:
return `${diffHour}時間${diffStr}`;
case diffDay < 30:
return `${diffDay}${diffStr}`;
case diffMonth < 12:
return `${diffMonth}ヶ月${diffStr}`;
default:
return `${diffYear}${diffStr}`;
}
}
+45 -9
View File
@@ -1,27 +1,63 @@
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;
const resources = {
"general": [
"/assets/lynqchat.svg",
],
};
// @ts-expect-error
const manifest = self.__WB_MANIFEST || [];
manifest.forEach((entry: any) => {
const url = typeof entry === "string"
? entry
: entry.url;
resources.general.push(url);
});
swSelf.addEventListener("install", (event) => {
event.waitUntil(swSelf.skipWaiting());
swSelf.skipWaiting();
event.waitUntil((async () => {
await Promise.all(Object.entries(resources).map(async ([name, assets]) => {
const cache = await caches.open(name);
for (const asset of assets) {
try {
await cache.add(asset);
} catch (error) {
console.error(`Failed to cache asset: ${asset}`, error);
}
}
}));
})());
});
swSelf.addEventListener("activate", (event) => {
event.waitUntil(swSelf.clients.claim());
event.waitUntil((async () => {
await swSelf.clients.claim();
})());
});
swSelf.addEventListener("fetch", (event) => {
const request = event.request;
if (request.url.indexOf("http") !== 0)
if (request.method !== "GET")
return;
const url = new URL(request.url);
if (url.origin !== location.origin)
return;
event.respondWith((async () => {
const cached = await caches.match(request);
if (cached)
return cached;
try {
const res = await fetch(request);
return res;
} catch (err) {
return new Response("Network error", {
status: 504,
});
return await fetch(request);
} catch (error) {
return new Response("Network error", { status: 504 });
}
})());
});
+1 -1
View File
@@ -75,7 +75,7 @@ const router = createRouter({
],
});
router.beforeEach((to, from) => {
if (to.path === from.path)
if (from.matched.length > 0 && to.path === from.path)
return false;
routerStatus.isLoad = true;
@@ -9,14 +9,114 @@
v-if="!isProcessing && channel"
class="channel"
>
<div class="messages" @scroll.passive="messagesScroll">
<div class="none-message" v-if="messages.length === 0">
<span>何ということでしょう</span>
<span>メッセージがありませんね</span>
</div>
<Progress
v-if="isLoading"
:size="10"
class="loading-progress"
/>
<Message
v-for="message of messages"
@deleteMessage="deleteMessage"
:key="message.id"
:message="message"
/>
</div>
<form>
<textarea
@input="resize"
@keydown.ctrl.enter="send"
class="message-input"
rows="1"
v-model="message"
:disabled="isSending"
:placeholder="`${channel.name}に送信`"
/>
<Icon
@click="send"
:icon='isSending
? "line-md:loading-twotone-loop"
: "material-symbols:send-rounded"'
/>
</form>
</div>
</template>
<style scoped>
.processing-progress {
margin: auto;
}
.channel {
position: relative;
display: flex;
flex-direction: column-reverse;
flex-direction: column;
width: 100%;
height: 100%;
}
.messages {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
overflow: scroll;
}
.none-message {
margin: auto;
display: flex;
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.none-message span:first-child {
font-size: 1.25rem;
font-weight: bold;
}
.loading-progress {
margin: 0 auto;
}
.channel form {
display: flex;
gap: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 1rem;
background-color: var(--bg-sub-color);
}
.message-input {
width: 100%;
max-height: 40dvh;
resize: none;
border: none;
outline: none;
color: var(--text-color);
background-color: var(--bg-sub-color);
}
.message-input:disabled {
color: oklch(from var(--text-color) calc(l - 0.2) c h);
cursor: not-allowed;
}
.channel form svg {
cursor: pointer;
flex-shrink: 0;
margin-top: auto;
width: 2rem;
height: 2rem;
}
</style>
@@ -24,18 +124,18 @@
import { useRoute, useRouter } from "vue-router";
import { account, presentCommunity, serverInfo } from "@/lib/account";
import client from "@/lib/client";
import { createModal } from "@/lib/modal";
import { createModal, createTopNotice } from "@/lib/modal";
import GoHomeError from "@/components/Modal/GoHomeError.vue";
import { ref } from "vue";
import Progress from "@/components/Progress.vue";
import { title } from "@/lib/router";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import { Icon } from "@iconify/vue";
import ErrorModal from "@/components/Modal/Error.vue";
import Message from "@/components/Message.vue";
const route = useRoute();
const router = useRouter();
const isProcessing = ref<boolean>(false);
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
if (!serverInfo.value.success) {
throw new Error("サーバー情報の取得に失敗しました。");
@@ -55,6 +155,176 @@ if (!account.value.success) {
}
}
const isProcessing = ref<boolean>(false);
const isSending = ref<boolean>(false);
const channel = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"][number]>();
const messages = ref<Extract<ApiMap["message/list"]["response"], { messages: any }>["messages"]>([]);
const message = ref<string>("");
const isInsideRange = ref(false);
const isLoading = ref(false);
const messagesScroll = async (event: any) => {
const handleSize = 200;
const isCurrentInside = event.target.scrollTop < handleSize;
if (isCurrentInside && !isInsideRange.value && !isLoading.value) {
isLoading.value = true;
await new Promise<void>(resolve => setTimeout(resolve, 0));
document.querySelector(".messages")!.scrollTop = 0;
if (channel.value === undefined) {
isLoading.value = false;
createModal({
component: ErrorModal,
onClose: () => isSending.value = false,
props: {
error: "チャンネルの情報がありません。",
canClose: true,
},
});
return;
}
const messagesRes = await client.value.request("message/list", {
channel: channel.value.id,
until: messages.value[0]?.id,
});
if (!messagesRes.success) {
isLoading.value = false;
createModal({
component: ErrorModal,
onClose: async () => await router.push("/"),
props: {
error: "メッセージが取得できませんでした。",
canClose: true,
},
});
return;
}
messages.value = messagesRes.messages.toReversed().concat(messages.value);
isLoading.value = false;
}
isInsideRange.value = isCurrentInside;
}
const resize = (event: any) => {
event.target.style.height = "auto";
event.target.style.height = event.target.scrollHeight + 'px';
}
const send = async (e: Event) => {
e.preventDefault();
isSending.value = true;
if (channel.value === undefined) {
createModal({
component: ErrorModal,
onClose: () => isSending.value = false,
props: {
error: "チャンネルの情報がありません。",
canClose: true,
},
});
return;
}
if (message.value.trim().length > 4096) {
isSending.value = false;
createTopNotice({
props: {
icon: "material-symbols:warning-rounded",
iconColor: "var(--warn-color)",
error: "メッセージは4096文字までである必要があります。",
},
});
return;
}
if (message.value.trim().length === 0) {
isSending.value = false;
createTopNotice({
props: {
icon: "material-symbols:warning-rounded",
iconColor: "var(--warn-color)",
message: "メッセージを空にすることは出来ません。",
},
});
return;
}
const channelId = route.params.channelId;
if (typeof channelId !== "string") {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "チャンネルが取得できませんでした。",
canClose: true,
},
});
return;
}
if (!account.value.success) {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "アカウント情報の取得に失敗しました。",
canClose: true,
},
});
return;
}
const sendRes = await client.value.request("message/send", {
message: message.value,
channel: channel.value.id,
});
if (!sendRes.success) {
isSending.value = false;
createModal({
component: ErrorModal,
props: {
error: "メッセージの送信に失敗しました。",
canClose: true,
},
});
return;
}
messages.value = [...messages.value, {
id: sendRes.id,
message: message.value,
channel: channelId,
createdAt: new Date().toUTCString(),
createdBy: {
...account.value,
},
}];
message.value = "";
const inputElem = document.querySelector(".message-input") as HTMLTextAreaElement;
inputElem.style.height = "auto";
isSending.value = false;
await new Promise<void>(resolve => setTimeout(resolve, 0));
const messagesElem = document.querySelector(".messages")!;
messagesElem.scrollTop = messagesElem.scrollHeight;
}
const deleteMessage = (data: { id: string }) => {
const index = messages.value.findIndex(msg => msg.id === data.id);
if (index !== -1)
messages.value = messages.value.toSpliced(index, 1);
}
(async () => {
isProcessing.value = true;
@@ -108,6 +378,27 @@ if (!account.value.success) {
document.title = `${channelRes.channel.name} - ${presentCommunity.value.name} | ${serverInfo.value.name}`;
title.value = `${channelRes.channel.name} - ${presentCommunity.value.name}`;
const messagesRes = await client.value.request("message/list", {
channel: channel.value.id,
});
if (!messagesRes.success) {
isProcessing.value = false;
createModal({
component: GoHomeError,
onClose: async () => await router.push("/"),
props: {
error: "メッセージが取得できませんでした。",
},
});
return;
}
messages.value = messages.value.concat(messagesRes.messages).reverse();
isProcessing.value = false;
await new Promise<void>(resolve => setTimeout(resolve, 0));
const messagesElem = document.querySelector(".messages")!;
messagesElem.scrollTop = messagesElem.scrollHeight;
})();
</script>
@@ -38,27 +38,33 @@
}
.community {
width: 100%;
height: 100%;
display: flex;
padding-left: 1rem;
padding-right: 1rem;
}
.channels {
display: flex;
flex-direction: column;
padding: 1rem;
padding-top: 1rem;
box-sizing: border-box;
width: 16rem;
height: 100%;
border-top-right-radius: 2rem;
border-bottom-right-radius: 2rem;
}
.channels,
.border {
flex-shrink: 0;
}
.channels a {
display: flex;
color: var(--text-color);
text-decoration: none;
gap: 0.25rem;
width: 100%;
height: 2rem;
border: 1px solid transparent;
border-radius: 1rem;
@@ -70,7 +76,7 @@
.channels a:hover,
.channels a.isActive {
background-color: oklch(from var(--route-bg-color) calc(l - 0.02) c h);
background-color: var(--bg-sub-color);
}
.channels a.isActive {
@@ -102,6 +108,12 @@
padding-top: 1rem;
flex-grow: 1;
height: 100%;
box-sizing: border-box;
}
.community-inner {
display: flex;
flex-direction: column;
}
</style>
+13 -14
View File
@@ -11,20 +11,19 @@ export default defineConfig(({ mode }) => {
plugins: [
vue(),
VitePWA({
strategies: "injectManifest",
srcDir: "./src/lib",
filename: "sw.ts",
injectRegister: false,
manifest: false,
injectManifest: {
injectionPoint: undefined,
},
devOptions: {
enabled: true,
type: "module",
},
}),
strategies: "injectManifest",
srcDir: "./src/lib",
filename: "sw.ts",
injectRegister: "inline",
manifest: false,
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
},
devOptions: {
enabled: true,
type: "module",
},
}),
],
server: {
hmr: {
@@ -0,0 +1,17 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Message from "../../modules/message";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface MessageDelete {
"message/delete": {
body: {
id: string;
};
response: Success | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,21 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Message from "../../modules/message";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface MessageList {
"message/list": {
body: {
channel: string;
limit?: number;
until?: string | Date;
};
response: (Success & {
messages: Message[];
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
@@ -0,0 +1,20 @@
import InputError from "../../modules/error/input";
import ErrorBase from "../../modules/error";
import DatabaseError from "../../modules/error/database";
import UnknownError from "../../modules/error/unknown";
import Success from "../../modules/response/success";
import Message from "../../modules/message";
import YetInitializationError from "../../modules/error/yet_init";
import AuthError from "../../modules/error/auth";
export default interface MessageSend {
"message/send": {
body: {
message: string;
channel: string;
};
response: (Success & {
id: string;
}) | InputError | AuthError | DatabaseError | YetInitializationError | UnknownError;
};
}
+7 -1
View File
@@ -7,6 +7,9 @@ import CommunityEdit from "./api/community/edit";
import CommunityGet from "./api/community/get";
import CommunityList from "./api/community/list";
import Me from "./api/me";
import MessageDelete from "./api/message/delete";
import MessageList from "./api/message/list";
import MessageSend from "./api/message/send";
import PrimarySignin from "./api/primary/signin";
import PrimarySignup from "./api/primary/signup";
import ServerInfo from "./api/server-info";
@@ -27,6 +30,9 @@ type ApiMap =
ChannelCreate &
ChannelEdit &
ChannelGet &
ChannelList;
ChannelList &
MessageDelete &
MessageSend &
MessageList;
export default ApiMap;
@@ -4,5 +4,5 @@ export default interface Channel {
description: string;
community: string;
createdBy: string;
createdAt: Date;
createdAt: string;
}
@@ -4,5 +4,5 @@ export default interface Community {
description: string;
icon: string | null;
createdBy: string;
createdAt: Date;
createdAt: string;
}
@@ -0,0 +1,9 @@
import UserSchema from "./user";
export default interface Message {
id: string;
message: string;
channel: string;
createdBy: Pick<UserSchema, "id" | "userid" | "username" | "icon">;
createdAt: string;
}
@@ -1,6 +1,8 @@
export default interface UserSchema {
id: string;
userid: string;
username: string;
icon: string | null;
profile: string;
email: string;
password: string;