messages!

This commit is contained in:
Stepan Usatiuk
2023-12-25 19:22:54 +01:00
parent 9910f38ec5
commit 96dd1065da
23 changed files with 451 additions and 35 deletions

View File

@@ -11,6 +11,7 @@ import { Login } from "./Login";
import { Signup } from "./Signup";
import { Home } from "./Home";
import {
chatAction,
homeAction,
loginAction,
newChatAction,
@@ -68,6 +69,7 @@ const router = createBrowserRouter([
path: "messages/chat/:id",
element: <Chat />,
loader: chatLoader,
action: chatAction,
},
{
path: "users",

60
client/src/Chat.scss Normal file
View File

@@ -0,0 +1,60 @@
@import "./common";
.chat {
width: 20rem;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: 2rem 0;
a {
color: black;
text-decoration: none;
}
.chatHeader {
display: flex;
flex-direction: row;
flex: auto;
border-bottom: solid 1px #A0A0A0;
span {
flex-grow: 1;
}
> :first-child {
margin-right: 1rem;
}
margin-bottom: 1rem;
padding-bottom: 0.2rem;
}
.chatMessages {
display: flex;
flex: auto;
a {
padding: 0.5rem 1rem;
margin: 0.3rem;
flex-grow: 0;
@include border-shadow;
}
}
.messageForm {
display: flex;
flex-direction: row;
margin-top: 0.5rem;
//margin: 2rem;
min-height: 3rem;
@include border-shadow;
border: solid gray 1px;
@include post-editor;
}
}

View File

@@ -1,12 +1,59 @@
import { useLoaderData } from "react-router-dom";
import { useFetcher, useLoaderData, useRevalidator } from "react-router-dom";
import { chatLoader, LoaderToType } from "./loaders";
import { isError } from "./api/dto";
import "./Chat.scss";
import "./PostForm.scss";
import { Message } from "./Message";
import { useEffect } from "react";
export function Chat() {
const loaderData = useLoaderData() as LoaderToType<typeof chatLoader>;
if (!loaderData || isError(loaderData)) {
if (!loaderData) {
return <div>error</div>;
}
return <div className={"chat"}>{loaderData.name}</div>;
const chat = loaderData.chat;
const messages = loaderData.messages;
if (!chat || isError(chat) || !messages || isError(messages)) {
return <div>error</div>;
}
const revalidator = useRevalidator();
useEffect(() => {
const interval = setInterval(() => revalidator.revalidate(), 1000);
return () => {
clearInterval(interval);
};
}, []);
const fetcher = useFetcher();
const busy = fetcher.state === "submitting";
const sortedMessages = messages.sort((a, b) => b.createdAt - a.createdAt);
return (
<div className={"chat"}>
<div className={"chatHeader"}>{chat.name}</div>
<fetcher.Form className={"messageForm postForm"} method="post">
<textarea placeholder={"Write something!"} name="text" />
<input hidden={true} value={chat.id} name={"chatId"} />
<button
name="intent"
value="addMessage"
type="submit"
disabled={busy}
>
Write
</button>
</fetcher.Form>
<div className={"messages"}>
{sortedMessages.map((m) => (
<Message key={m.id} message={m} chat={chat} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
.chatsNew {
width: 20rem;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: 2rem 0;
.errors {
margin: 2rem 0;
color: red;
}
form {
display: flex;
flex-direction: column;
* {
margin: 0.3rem 0;
}
label {
margin-bottom: 0;
}
input {
margin-top: 0;
}
}
}

View File

@@ -1,22 +1,41 @@
import { Form, useLoaderData, useNavigation } from "react-router-dom";
import {
Form,
useActionData,
useLoaderData,
useNavigation,
} from "react-router-dom";
import { LoaderToType, newChatLoader } from "./loaders";
import { isError } from "./api/dto";
import { getTokenUserUuid } from "./api/utils";
import "./ChatCreate.scss";
import { ActionToType, newChatAction } from "./actions";
export function ChatCreate() {
const loaderData = useLoaderData() as LoaderToType<typeof newChatLoader>;
if (!loaderData || isError(loaderData)) {
return <div>error</div>;
}
let errors: JSX.Element[] = [];
const actionData = useActionData() as ActionToType<typeof newChatAction>;
if (isError(actionData)) {
errors = actionData.errors.map((e) => {
return <a>{e}</a>;
});
}
const navigation = useNavigation();
const busy = navigation.state === "submitting";
return (
<div className={"chatsNew"}>
<div className={"errors"}>{errors}</div>
<Form method="post">
<label htmlFor="fname">name:</label>
<label htmlFor="fname">Chat name:</label>
<input type="text" name="name" />
<label htmlFor="members">Members:</label>
<select multiple name={"members"}>
{loaderData
.filter((p) => p.uuid != getTokenUserUuid())

View File

@@ -1,3 +1,46 @@
@import "./common";
.chats {
width: 20rem;
margin: 0 auto;
display: flex;
flex-direction: column;
padding: 2rem 0;
a {
color: black;
text-decoration: none;
}
.chatsHeader {
display: flex;
flex-direction: row;
flex: auto;
border-bottom: solid 1px #A0A0A0;
span {
flex-grow: 1;
}
> :first-child {
margin-right: 1rem;
}
margin-bottom: 1rem;
padding-bottom: 0.2rem;
}
.chatsList {
display: flex;
flex: auto;
a {
padding: 0.5rem 1rem;
margin: 0.3rem;
flex-grow: 0;
@include border-shadow;
}
}
}

View File

@@ -13,6 +13,7 @@ export function Chats() {
return (
<div className={"chats"}>
<div className={"chatsHeader"}>
<span>Your chats</span>
<Link to={"../messages/chats/new"}>create</Link>
</div>
<div className={"chatsList"}>

79
client/src/Message.scss Normal file
View File

@@ -0,0 +1,79 @@
@import "./common";
.message {
display: flex;
flex-direction: column;
border: 1px solid #E0E0E0;
border-radius: 7px;
padding: 7px;
resize: none;
margin: 1rem 0;
min-height: 3rem;
@include border-shadow;
&.messageEditing {
padding: 0;
min-height: 6rem;
border: 1px solid #D0D0D0;
@include post-editor;
}
.text {
word-wrap: anywhere;
}
.footer {
margin-top: 0.3rem;
margin-bottom: -0.3rem;
font-size: 0.7rem;
color: #A0A0A0;
display: flex;
flex: auto;
width: 100%;
.info {
flex-grow: 1;
align-self: start;
> * {
margin-left: 0.5rem;
}
> *:first-child {
margin-left: 0;
}
a {
color: inherit;
}
}
.actions {
display: flex;
align-self: end;
> * {
margin-left: 0.5rem;
}
> *:first-child {
margin-left: 0;
}
button {
background: none;
border: none;
padding: 0;
text-decoration: none;
cursor: pointer;
color: inherit;
font-size: inherit;
}
}
}
}

50
client/src/Message.tsx Normal file
View File

@@ -0,0 +1,50 @@
import "./Message.scss";
import { TChatTo, TMessageTo } from "./api/dto";
import { Link } from "react-router-dom";
export function Message({
message,
chat,
}: {
message: TMessageTo;
chat: TChatTo;
}) {
return (
<div className={"message"}>
<span className={"text"}>{message.contents}</span>
<div className={"footer"}>
<div className={"info"}>
<span className={"createdDate"}>
{new Date(message.createdAt * 1000).toUTCString()}
</span>
<Link
className={"authorLink"}
to={"/home/profile/" + message.authorUsername}
>
by {message.authorUsername}
</Link>
</div>
{/*{actions && (*/}
{/* <div className={"actions"}>*/}
{/* {<button onClick={() => setEditing(true)}>edit</button>}*/}
{/* <Form method={"delete"}>*/}
{/* <input*/}
{/* hidden={true}*/}
{/* name={"postToDeleteId"}*/}
{/* value={id}*/}
{/* />*/}
{/* <button*/}
{/* name="intent"*/}
{/* value="deletePost"*/}
{/* type={"submit"}*/}
{/* disabled={busy}*/}
{/* >*/}
{/* delete*/}
{/* </button>*/}
{/* </Form>*/}
{/* </div>*/}
{/*)}*/}
</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
export function Messages() {
return <a>Messages</a>;
}

View File

@@ -5,6 +5,7 @@ import { isError } from "./api/dto";
import { deleteToken, getTokenUserUuid, setToken } from "./api/utils";
import { createPost, deletePost, updatePost } from "./api/Post";
import { createChat } from "./api/Chat";
import { addMessagesToChat } from "./api/Message";
export type ActionToType<T extends (...args: any) => any> =
| Exclude<Awaited<ReturnType<T>>, Response>
@@ -89,8 +90,19 @@ export async function userListAction({ request }: ActionFunctionArgs) {
export async function newChatAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
return await createChat(formData.get("name")!.toString(), [
const ret = await createChat(formData.get("name")!.toString(), [
...formData.getAll("members")!.map((p) => p.toString()),
getTokenUserUuid()!,
]);
if (ret && !isError(ret)) {
return redirect("/home/messages/chat/" + ret.id);
} else return ret;
}
export async function chatAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
return await addMessagesToChat(
Number(formData.get("chatId")!.toString()),
formData.get("text")!.toString(),
);
}

22
client/src/api/Message.ts Normal file
View File

@@ -0,0 +1,22 @@
import {
MessagesToResp,
MessageToResp,
TMessagesToResp,
TMessageToResp,
} from "./dto";
import { fetchJSONAuth } from "./utils";
export async function getMessagesByChat(
chatId: number,
): Promise<TMessagesToResp> {
return fetchJSONAuth("/message/by-chat/" + chatId, "GET", MessagesToResp);
}
export async function addMessagesToChat(
chatId: number,
messageContents: string,
): Promise<TMessageToResp> {
return fetchJSONAuth("/message/by-chat/" + chatId, "POST", MessageToResp, {
contents: messageContents,
});
}

View File

@@ -79,19 +79,23 @@ export const MessageTo = z.object({
id: number(),
chatId: z.number(),
authorUuid: z.string(),
authorUsername: z.string(),
contents: z.string(),
createdAt: z.number(),
});
export type TMessageTo = z.infer<typeof MessageTo>;
export const MessageToResp = CreateAPIResponse(MessageTo);
export type TMessageToResp = z.infer<typeof MessageToResp>;
export const MessagesToResp = CreateAPIResponse(z.array(MessageTo));
export type TMessagesToResp = z.infer<typeof MessagesToResp>;
export const ChatTo = z.object({
id: z.number(),
name: z.string(),
creatorUuid: z.string(),
members: z.array(PersonTo),
messages: z.array(MessageTo),
memberCount: z.number(),
});
export type TChatTo = z.infer<typeof ChatTo>;

View File

@@ -13,6 +13,7 @@ import {
getPostsByFollowees,
} from "./api/Post";
import { getChat, getMyChats } from "./api/Chat";
import { getMessagesByChat } from "./api/Message";
export type LoaderToType<T extends (...args: any) => any> =
| Exclude<Awaited<ReturnType<T>>, Response>
@@ -86,5 +87,8 @@ export async function newChatLoader() {
}
export async function chatLoader({ params }: { params: { id?: number } }) {
return getChat(params.id!);
return {
chat: await getChat(params.id!),
messages: await getMessagesByChat(params.id!),
};
}