Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions common/zod/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,33 @@ export type MYDATA = Omit<StructuredUser, "university"> & {
allowNotifications: boolean;
allowPeriodicNotifications: boolean;
};

// chat types, maybe consider creating schemas for this?
type Room = {
id: string;
members: {
user: {
id: string;
name: string;
imageUrl: string | null;
};
}[];
};
export type RoomPreview = Room & {
lastMessage: string | null;
};
export type Message = {
id: string;
roomId: string;
senderId: string;
content: string;
createdAt: Date;
isPhoto: boolean;
isEdited: boolean;
sender: {
name: string;
};
};
export type ContentfulRoom = Room & {
messages: Message[];
};
28 changes: 14 additions & 14 deletions server/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,7 @@ import { streamSSE } from "hono/streaming";
import z from "zod";
import { prisma } from "../config/prisma.ts";

// TODO: use types from schema
import type { Message as PrismaMessage } from "@prisma/client";
export type { Room } from "@prisma/client";
export type Message = PrismaMessage & {
sender: {
name: string;
};
};

import { MESSAGE_MAX_LENGTH } from "common/zod/schema.ts";
import { MESSAGE_MAX_LENGTH, type Message } from "common/zod/schema.ts";
import { HTTPException } from "hono/http-exception";
import { getUserID } from "../auth/func.ts";
import { onMessageSend } from "../email/hooks/onMessageSend.ts";
Expand Down Expand Up @@ -192,7 +183,14 @@ const router = new Hono()
},
});

return c.json(resp, 200);
return c.json(
resp.map((it) => ({
...it,
lastMessage: it.messages[0]?.content ?? null,
messages: undefined,
})),
200,
);
})
// ## room data
.get(
Expand Down Expand Up @@ -305,7 +303,7 @@ const router = new Hono()
},
},
});
if (!resp) return c.json({ error: "room not found" }, 404);
if (!resp) throw new HTTPException(404, { message: "room not found" });
return c.json(resp, 200);
},
)
Expand All @@ -332,7 +330,7 @@ const router = new Hono()
const { room: roomId } = c.req.valid("param");
const json = c.req.valid("json");

const message: PrismaMessage = {
const message = {
...json,
roomId,
id: randomUUIDv7(),
Expand Down Expand Up @@ -379,7 +377,9 @@ const router = new Hono()
}
})();
const resp = await prisma.message.create({
data: message,
data: {
...message,
},
});
return c.json(resp, 201);
},
Expand Down
19 changes: 13 additions & 6 deletions server/routes/users/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,25 @@ const route = new Hono()
: {}),
},
include: {
fluentLanguages: {
select: { language: true },
},
learningLanguages: {
select: { language: true },
},
campus: {
include: {
university: true,
},
},
division: true,
motherLanguage: true,
fluentLanguages: {
include: {
language: true,
},
},
learningLanguages: {
include: {
language: true,
},
},
marking: true,
markedAs: true,
},
});

Expand Down
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
// "noUncheckedSideEffectImports": true,
// ?
"incremental": true,
Expand Down
63 changes: 63 additions & 0 deletions web/src/app/[locale]/(auth)/chat/[id]/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import { client } from "@/client";
import { useAuthContext } from "@/features/auth/providers/AuthProvider";
import clsx from "clsx";
import { MESSAGE_MAX_LENGTH } from "common/zod/schema";
import { useState } from "react";
import { AiOutlineSend } from "react-icons/ai";

export function MessageInput({ roomId }: { roomId: string }) {
const { idToken: Authorization } = useAuthContext();
const [input, setInput] = useState<string>("");
const [submitting, setSubmitting] = useState<boolean>(false);
const isSendButtonDisabled = submitting || input === "";

const handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (submitting) return;
setSubmitting(true);
setInput("");
await client.chat.rooms[":room"].messages.$post({
header: { Authorization },
param: {
room: roomId,
},
json: {
content: input,
isPhoto: false,
},
});
setSubmitting(false);
};

return (
<div className="">
<form className="inline" onSubmit={handleSubmit}>
<div className="fixed bottom-[64px] flex w-full flex-row justify-around gap-2 border-gray-300 border-t bg-white p-4 sm:bottom-0">
<textarea
className={clsx(
"field-sizing-content h-auto max-h-[200px] min-h-[40px] w-full resize-none rounded-xl border border-gray-300 p-2 leading-relaxed focus:outline-none focus:ring-2 focus:ring-blue-500",
input.length >= MESSAGE_MAX_LENGTH && "bg-red-200",
)}
rows={1}
maxLength={MESSAGE_MAX_LENGTH}
value={input}
onChange={(ev) => {
setInput(ev.target.value);
}}
onKeyDown={(ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
ev.currentTarget.form?.requestSubmit();
}
}}
/>
<button type="submit" className="" disabled={isSendButtonDisabled}>
<AiOutlineSend size={30} color={isSendButtonDisabled ? "gray" : "#0b8bee"} />
</button>
</div>
</form>
</div>
);
}
176 changes: 176 additions & 0 deletions web/src/app/[locale]/(auth)/chat/[id]/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"use client";

import { client } from "@/client";
import { useAuthContext } from "@/features/auth/providers/AuthProvider";
import { handlers } from "@/features/chat/state";
import { useUserContext } from "@/features/user/userProvider";
import type { ContentfulRoom } from "common/zod/schema.ts";
import { useEffect, useRef, useState } from "react";

export function MessageList({
data,
}: {
data: ContentfulRoom;
}) {
const [messages, setMessages] = useState(data.messages);
const { idToken: Authorization } = useAuthContext();

useEffect(() => {
handlers.onCreate = (message) => {
console.log("onCreate: updating messages...");
if (data.id === message.roomId) {
setMessages((prev) => {
// avoid react from automatically optimizing the update away
return [...prev, message];
});
return true;
}
return false;
};
handlers.onUpdate = (id, newMessage) => {
setMessages((prev) => {
for (const m of prev) {
if (m.id === id) {
m.content = newMessage.content;
}
}
// avoid react from automatically optimizing the update away
return [...prev];
});
};
handlers.onDelete = (id) => {
setMessages((prev) => {
return prev.filter((m) => m.id !== id);
});
};
return () => {
handlers.onCreate = undefined;
handlers.onUpdate = undefined;
handlers.onDelete = undefined;
};
}, [data.id]);
const { me } = useUserContext();

const target = document.getElementById("scroll-bottom");
if (target) {
target.scrollIntoView(false);
}

const bottomRef = useRef<HTMLDivElement>(null);

useEffect(() => {
messages;
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);

const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
const longPressTimer = useRef<NodeJS.Timer | null>(null);

const handleRequestDelete = (id: string) => {
setDeletingMessageId(id);
setShowConfirmModal(true);
};

const handleDelete = async () => {
if (!deletingMessageId) return;

try {
await client.chat.messages[":message"][":room"].$delete({
header: { Authorization },
param: { message: deletingMessageId, room: data.id },
});

setMessages((prev) => prev.filter((m) => m.id !== deletingMessageId));

setDeletingMessageId(null);
setShowConfirmModal(false);
} catch (error) {
alert("削除に失敗しました");
}
};

const handleEdit = (id: string) => {
console.log("編集", id);
setSelectedMessageId(null);
};

const handleLongPressStart = (id: string) => {
longPressTimer.current = setTimeout(() => {
setSelectedMessageId(id);
}, 600); // 600ms 長押しで発動
};

const handleLongPressEnd = () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
};

return (
<ul className="mx-3 mb-[76px] grow overflow-y-scroll sm:pb-0" id="scroll-bottom">
{messages.map((m) => (
// TODO: handle pictures
<li key={m.id}>
<div
className={`chat ${m.senderId === me.id ? "chat-end" : "chat-start"}`}
onTouchStart={() => handleLongPressStart(m.id)}
onTouchEnd={handleLongPressEnd}
onMouseDown={() => handleLongPressStart(m.id)}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
>
<div className="chat-header">
<time className="text-xs opacity-50">{m.createdAt.toLocaleString()}</time>
</div>
<div
className={`chat-bubble max-w-[80vw] break-words ${m.senderId === me.id ? "bg-blue-200" : "chat-start"}`}
>
{m.content.split("\n").map((line, index) => (
<div key={`${m.id}-${index}`}>
{line}
<br />
</div>
))}

{selectedMessageId === m.id && (
<div className="absolute top-0 right-0 z-10 flex gap-1 rounded border bg-white p-1 shadow">
<button
type="button"
className="text-blue-600 text-sm hover:underline"
onClick={() => handleEdit(m.id)}
>
編集
</button>
<button type="button" onClick={() => handleRequestDelete(m.id)} className="text-red-600">
削除
</button>
</div>
)}
</div>
{/* <div className="chat-footer opacity-50">Seen</div> */}
{showConfirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="rounded-lg bg-white p-4 shadow-lg">
<p className="mb-4">このメッセージを削除しますか?</p>
<div className="flex justify-end gap-3">
<button
type="button"
className="rounded bg-gray-300 px-4 py-2"
onClick={() => setShowConfirmModal(false)}
>
キャンセル
</button>
<button type="button" className="rounded bg-red-500 px-4 py-2 text-white" onClick={handleDelete}>
削除
</button>
</div>
</div>
</div>
)}
</div>
</li>
))}
<div ref={bottomRef} />
</ul>
);
}
Loading