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 { Signup } from "./Signup";
import { Home } from "./Home"; import { Home } from "./Home";
import { import {
chatAction,
homeAction, homeAction,
loginAction, loginAction,
newChatAction, newChatAction,
@@ -68,6 +69,7 @@ const router = createBrowserRouter([
path: "messages/chat/:id", path: "messages/chat/:id",
element: <Chat />, element: <Chat />,
loader: chatLoader, loader: chatLoader,
action: chatAction,
}, },
{ {
path: "users", 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 { chatLoader, LoaderToType } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import "./Chat.scss";
import "./PostForm.scss";
import { Message } from "./Message";
import { useEffect } from "react";
export function Chat() { export function Chat() {
const loaderData = useLoaderData() as LoaderToType<typeof chatLoader>; const loaderData = useLoaderData() as LoaderToType<typeof chatLoader>;
if (!loaderData || isError(loaderData)) {
if (!loaderData) {
return <div>error</div>; 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 { LoaderToType, newChatLoader } from "./loaders";
import { isError } from "./api/dto"; import { isError } from "./api/dto";
import { getTokenUserUuid } from "./api/utils"; import { getTokenUserUuid } from "./api/utils";
import "./ChatCreate.scss";
import { ActionToType, newChatAction } from "./actions";
export function ChatCreate() { export function ChatCreate() {
const loaderData = useLoaderData() as LoaderToType<typeof newChatLoader>; const loaderData = useLoaderData() as LoaderToType<typeof newChatLoader>;
if (!loaderData || isError(loaderData)) { if (!loaderData || isError(loaderData)) {
return <div>error</div>; 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 navigation = useNavigation();
const busy = navigation.state === "submitting"; const busy = navigation.state === "submitting";
return ( return (
<div className={"chatsNew"}> <div className={"chatsNew"}>
<div className={"errors"}>{errors}</div>
<Form method="post"> <Form method="post">
<label htmlFor="fname">name:</label> <label htmlFor="fname">Chat name:</label>
<input type="text" name="name" /> <input type="text" name="name" />
<label htmlFor="members">Members:</label>
<select multiple name={"members"}> <select multiple name={"members"}>
{loaderData {loaderData
.filter((p) => p.uuid != getTokenUserUuid()) .filter((p) => p.uuid != getTokenUserUuid())

View File

@@ -1,3 +1,46 @@
@import "./common";
.chats { .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 ( return (
<div className={"chats"}> <div className={"chats"}>
<div className={"chatsHeader"}> <div className={"chatsHeader"}>
<span>Your chats</span>
<Link to={"../messages/chats/new"}>create</Link> <Link to={"../messages/chats/new"}>create</Link>
</div> </div>
<div className={"chatsList"}> <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 { deleteToken, getTokenUserUuid, setToken } from "./api/utils";
import { createPost, deletePost, updatePost } from "./api/Post"; import { createPost, deletePost, updatePost } from "./api/Post";
import { createChat } from "./api/Chat"; import { createChat } from "./api/Chat";
import { addMessagesToChat } from "./api/Message";
export type ActionToType<T extends (...args: any) => any> = export type ActionToType<T extends (...args: any) => any> =
| Exclude<Awaited<ReturnType<T>>, Response> | Exclude<Awaited<ReturnType<T>>, Response>
@@ -89,8 +90,19 @@ export async function userListAction({ request }: ActionFunctionArgs) {
export async function newChatAction({ request }: ActionFunctionArgs) { export async function newChatAction({ request }: ActionFunctionArgs) {
const formData = await request.formData(); 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()), ...formData.getAll("members")!.map((p) => p.toString()),
getTokenUserUuid()!, 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(), id: number(),
chatId: z.number(), chatId: z.number(),
authorUuid: z.string(), authorUuid: z.string(),
authorUsername: z.string(),
contents: z.string(), contents: z.string(),
createdAt: z.number(),
}); });
export type TMessageTo = z.infer<typeof MessageTo>; export type TMessageTo = z.infer<typeof MessageTo>;
export const MessageToResp = CreateAPIResponse(MessageTo); export const MessageToResp = CreateAPIResponse(MessageTo);
export type TMessageToResp = z.infer<typeof MessageToResp>; 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({ export const ChatTo = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
creatorUuid: z.string(), creatorUuid: z.string(),
members: z.array(PersonTo), memberCount: z.number(),
messages: z.array(MessageTo),
}); });
export type TChatTo = z.infer<typeof ChatTo>; export type TChatTo = z.infer<typeof ChatTo>;

View File

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

View File

@@ -37,6 +37,9 @@ public class ChatController {
if (Arrays.stream(chatCreateTo.memberUuids()).noneMatch(n -> Objects.equals(n, principal.getName()))) if (Arrays.stream(chatCreateTo.memberUuids()).noneMatch(n -> Objects.equals(n, principal.getName())))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Creator of chat must be its member"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Creator of chat must be its member");
if (chatCreateTo.memberUuids().length <= 1)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat must have members other than its creator");
chat.setCreator(entityManager.getReference(Person.class, principal.getName())); chat.setCreator(entityManager.getReference(Person.class, principal.getName()));
chat.setMembers(Arrays.stream(chatCreateTo.memberUuids()).map( chat.setMembers(Arrays.stream(chatCreateTo.memberUuids()).map(
p -> entityManager.getReference(Person.class, p) p -> entityManager.getReference(Person.class, p)

View File

@@ -1,10 +1,55 @@
package com.usatiuk.tjv.y.server.controller; package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.MessageCreateTo;
import com.usatiuk.tjv.y.server.dto.MessageTo;
import com.usatiuk.tjv.y.server.dto.converters.MessageMapper;
import com.usatiuk.tjv.y.server.entity.Message;
import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.service.ChatService;
import com.usatiuk.tjv.y.server.service.MessageService;
import jakarta.persistence.EntityManager;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException;
import java.security.Principal;
import java.util.stream.Stream;
@RestController @RestController
@RequestMapping(value = "/message", produces = MediaType.APPLICATION_JSON_VALUE) @RequestMapping(value = "/message", produces = MediaType.APPLICATION_JSON_VALUE)
public class MessageController { public class MessageController {
private final ChatService chatService;
private final EntityManager entityManager;
private final MessageMapper messageMapper;
private final MessageService messageService;
public MessageController(ChatService chatService, EntityManager entityManager, MessageMapper messageMapper, MessageService messageService) {
this.chatService = chatService;
this.entityManager = entityManager;
this.messageMapper = messageMapper;
this.messageService = messageService;
}
@GetMapping(path = "/by-chat/{chatTd}")
public Stream<MessageTo> get(Principal principal, @PathVariable Long chatTd) {
var chat = chatService.readById(chatTd).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Chat not found"));
var userRef = entityManager.getReference(Person.class, principal.getName());
if (!chat.getMembers().contains(userRef))
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User isn't member of the chat");
return chat.getMessages().stream().map(messageMapper::makeDto);
}
@PostMapping(path = "/by-chat/{chatId}")
public MessageTo post(Principal principal, @PathVariable Long chatId, @RequestBody MessageCreateTo messageCreateTo) {
var chat = chatService.readById(chatId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Chat not found"));
var userRef = entityManager.getReference(Person.class, principal.getName());
if (!chat.getMembers().contains(userRef))
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User isn't member of the chat");
Message message = new Message().setChat(chat).setAuthor(userRef).setContents(messageCreateTo.contents());
messageService.create(message);
return messageMapper.makeDto(message);
}
} }

View File

@@ -1,8 +1,5 @@
package com.usatiuk.tjv.y.server.dto; package com.usatiuk.tjv.y.server.dto;
import java.util.Collection; public record ChatTo(Long id, String name, String creatorUuid, Long memberCount) {
public record ChatTo(Long id, String name, String creatorUuid, Collection<PersonTo> members,
Collection<MessageTo> messages) {
} }

View File

@@ -0,0 +1,4 @@
package com.usatiuk.tjv.y.server.dto;
public record MessageCreateTo(String contents) {
}

View File

@@ -1,4 +1,5 @@
package com.usatiuk.tjv.y.server.dto; package com.usatiuk.tjv.y.server.dto;
public record MessageTo(Long id, Long chatId, String authorUuid, String contents) { public record MessageTo(Long id, Long chatId, String authorUuid, String authorUsername, String contents,
Long createdAt) {
} }

View File

@@ -6,18 +6,7 @@ import org.springframework.stereotype.Component;
@Component @Component
public class ChatMapper { public class ChatMapper {
private final PersonMapper personMapper;
private final MessageMapper messageMapper;
public ChatMapper(PersonMapper personMapper, MessageMapper messageMapper) {
this.personMapper = personMapper;
this.messageMapper = messageMapper;
}
public ChatTo makeDto(Chat chat) { public ChatTo makeDto(Chat chat) {
return new ChatTo(chat.getId(), chat.getName(), chat.getCreator().getUuid(), return new ChatTo(chat.getId(), chat.getName(), chat.getCreator().getUuid(), (long) chat.getMembers().size());
chat.getMembers().stream().map(personMapper::makeDto).toList(),
chat.getMessages().stream().map(messageMapper::makeDto).toList());
} }
} }

View File

@@ -8,6 +8,6 @@ import org.springframework.stereotype.Component;
public class MessageMapper { public class MessageMapper {
public MessageTo makeDto(Message message) { public MessageTo makeDto(Message message) {
return new MessageTo(message.getId(), message.getChat().getId(), return new MessageTo(message.getId(), message.getChat().getId(),
message.getAuthor().getUuid(), message.getContents()); message.getAuthor().getUuid(), message.getAuthor().getUsername(), message.getContents(), message.getCreatedAt().getEpochSecond());
} }
} }

View File

@@ -7,6 +7,9 @@ import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import lombok.experimental.Accessors; import lombok.experimental.Accessors;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity @Entity
@Getter @Getter
@@ -25,6 +28,9 @@ public class Message implements EntityWithId<Long> {
@ManyToOne @ManyToOne
private Person author; private Person author;
@CreationTimestamp
private Instant createdAt;
@Lob @Lob
@NotBlank @NotBlank
private String contents; private String contents;

View File

@@ -1,3 +1,5 @@
jwt.secret=JKLASJKLASJKLJHKLDFAHJKFDSHJKFJHKDSHJKFHJKSDFJHKSDJHKFJHKS98346783467899782345jkhgsdoigh938g jwt.secret=JKLASJKLASJKLJHKLDFAHJKFDSHJKFJHKDSHJKFHJKSDFJHKSDJHKFJHKS98346783467899782345jkhgsdoigh938g
logging.level.root=DEBUG logging.level.root=DEBUG
logging.level.org.springframework.security=DEBUG logging.level.org.springframework.security=DEBUG
spring.datasource.url=jdbc:h2:file:~/tjvserver.h2
spring.jpa.hibernate.ddl-auto=update