From e7f94445da54cdf368e7ffa157f84449f33ffafe Mon Sep 17 00:00:00 2001 From: MeowcaTheoRange Date: Mon, 25 Sep 2023 12:45:33 -0500 Subject: [PATCH] Messaging update (v0.2) --- src/components/Nav/Nav.tsx | 8 + .../cards/MessageCard/MessageCard.module.css | 53 ++++++ .../cards/MessageCard/MessageCard.tsx | 80 ++++++++ .../cards/MessageCard/MessageSkeleton.tsx | 29 +++ src/components/cards/TrollCard/TrollCard.tsx | 4 +- src/components/form/template/message.tsx | 172 ++++++++++++++++++ src/lib/db/crud.ts | 2 +- src/lib/trollcall/api/message.ts | 33 ++++ src/lib/trollcall/convert/message.ts | 26 +++ src/lib/trollcall/message.ts | 89 +++++++++ src/pages/add/message/index.tsx | 34 ++++ .../message/[clan]/.../[[...page]]/index.ts | 51 ++++++ src/pages/api/message/[clan]/index.ts | 28 +++ src/pages/api/message/index.ts | 44 +++++ src/pages/clan/[clan]/index.tsx | 46 ++++- src/pages/troll/[clan]/[troll]/index.tsx | 25 ++- src/types/client/message.ts | 8 + src/types/message.ts | 23 +++ tsconfig.json | 7 +- 19 files changed, 752 insertions(+), 10 deletions(-) create mode 100644 src/components/cards/MessageCard/MessageCard.module.css create mode 100644 src/components/cards/MessageCard/MessageCard.tsx create mode 100644 src/components/cards/MessageCard/MessageSkeleton.tsx create mode 100644 src/components/form/template/message.tsx create mode 100644 src/lib/trollcall/api/message.ts create mode 100644 src/lib/trollcall/convert/message.ts create mode 100644 src/lib/trollcall/message.ts create mode 100644 src/pages/add/message/index.tsx create mode 100644 src/pages/api/message/[clan]/.../[[...page]]/index.ts create mode 100644 src/pages/api/message/[clan]/index.ts create mode 100644 src/pages/api/message/index.ts create mode 100644 src/types/client/message.ts create mode 100644 src/types/message.ts diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index 7bfbb3f..375c536 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -73,6 +73,14 @@ export default function Nav(elementProps: AnyObject) { userCredentials.TROLLCALL_NAME != null && userCredentials.TROLLCALL_NAME != "" ? ( <> + + + send + + * { + width: 100%; + text-overflow: ellipsis; + /* overflow: hidden; */ +} diff --git a/src/components/cards/MessageCard/MessageCard.tsx b/src/components/cards/MessageCard/MessageCard.tsx new file mode 100644 index 0000000..b6f33cb --- /dev/null +++ b/src/components/cards/MessageCard/MessageCard.tsx @@ -0,0 +1,80 @@ +/* eslint-disable @next/next/no-img-element */ +import Box from "@/components/Box/Box"; +import globals from "@/styles/global.module.css"; +import { ClientClan } from "@/types/clan"; +import { ClientMessage } from "@/types/message"; +import AuthContext from "@/utility/react/AuthContext"; +import Conditional from "@/utility/react/Conditional"; +import Link from "next/link"; +import { useContext } from "react"; +import styles from "./MessageCard.module.css"; + +export default function MessageCard({ + message, + recipient +}: { + message: ClientMessage; + recipient: ClientClan; +}) { + const userCredentials = useContext(AuthContext); + return ( + +
+ +
+ +
+
+
+ +

+ {message.from.displayName ?? message.from.name} +

+ + {/*

+ to {recipient.displayName ?? recipient.name} +

*/} +
+ +

{message.subject}

+
+ +

{message.body}

+
+
+
+
+ + + + {message._id} +
+
+ ); +} diff --git a/src/components/cards/MessageCard/MessageSkeleton.tsx b/src/components/cards/MessageCard/MessageSkeleton.tsx new file mode 100644 index 0000000..ecd08b6 --- /dev/null +++ b/src/components/cards/MessageCard/MessageSkeleton.tsx @@ -0,0 +1,29 @@ +/* eslint-disable @next/next/no-img-element */ +import Box from "@/components/Box/Box"; +import globals from "@/styles/global.module.css"; +import styles from "./MessageCard.module.css"; + +export default function MessageSkeleton() { + return ( + +
+
+

Tim Sweeney

+ {/*

+ to {recipient.displayName ?? recipient.name} +

*/} +
+

12%

+

+ hey did you know we only take 12%? did you know? did + you? hey did you know we +

+
+
+
+ ); +} diff --git a/src/components/cards/TrollCard/TrollCard.tsx b/src/components/cards/TrollCard/TrollCard.tsx index 3413b22..72a690d 100644 --- a/src/components/cards/TrollCard/TrollCard.tsx +++ b/src/components/cards/TrollCard/TrollCard.tsx @@ -101,8 +101,8 @@ export default function TrollCard({
diff --git a/src/components/form/template/message.tsx b/src/components/form/template/message.tsx new file mode 100644 index 0000000..cd4363e --- /dev/null +++ b/src/components/form/template/message.tsx @@ -0,0 +1,172 @@ +import Box from "@/components/Box/Box"; +import ErrorHandler from "@/components/form/ErrorHandler/ErrorHandler"; +import globals from "@/styles/global.module.css"; +import form_globals from "@/styles/global_form.module.css"; +import { SubmitMessage, SubmitMessageSchema } from "@/types/client/message"; +import { ErrorMessage, Field, Form, Formik } from "formik"; +import { NextRouter } from "next/router"; +import { useState } from "react"; + +export default function MessageFormTemplate({ + router, + onSubmitURI, + on200URI, + initialValues, + method +}: { + router: NextRouter; + initialValues?: SubmitMessage; + onSubmitURI?: string; + on200URI?: string; + method?: string; +}) { + const [submitError, setSubmitError] = useState(""); + return ( + { + fetch(onSubmitURI ?? "/api/message", { + method: method ?? "POST", + body: JSON.stringify(values), + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + } + }).then(res => { + if (res.status === 200) router.push(on200URI ?? `/clan/${values.to}`); + else setSubmitError(res.status.toString()); + }); + }} + > + {({ values, setFieldValue, errors, initialValues, isSubmitting, resetForm, submitForm }) => ( +
+ +
+
+

Recipient

+ + Who will recieve this message. Type the name of the user (the part you will find in their URL, not the display name.) + +
+
+
+ +
+ +
+
+
+
+
+

Subject

+ What your message is about. Can be left blank. +
+
+
+ +
+ +
+
+
+
+
+

Body

+ The main text of your message. +
+
+ + 10000 ? form_globals.render_error : ""} ${ + values.body.length >= 9900 ? form_globals.render_warning : "" + }`} + > + {values.body.length <= 10000 + ? 10000 - values.body.length + " characters remaining" + : values.body.length - 10000 + " characters over limit"} + + +
+
+
+ +
+ + +
+
{submitError != null && submitError.length > 0 ? ErrorHandler(submitError) : ""}
+
+ Advanced stuff +
+
+ Values (Advanced) +

{JSON.stringify(values, null, 2)}

+
+
+ Errors (Advanced) +

{JSON.stringify(errors, null, 2)}

+
+

If something is wrong, make sure to send these with your report!

+
+
+
+
+ )} +
+ ); +} diff --git a/src/lib/db/crud.ts b/src/lib/db/crud.ts index 739516e..9f98db6 100644 --- a/src/lib/db/crud.ts +++ b/src/lib/db/crud.ts @@ -47,7 +47,7 @@ export async function updateOne(collection: string, find: any, update: any) { export async function deleteOne(collection: string, find: any) { const selectedCollection = mainDB.collection(collection); - return await selectedCollection.findOneAndDelete(find); + return await selectedCollection.deleteOne(find); } export async function cursorToArray( diff --git a/src/lib/trollcall/api/message.ts b/src/lib/trollcall/api/message.ts new file mode 100644 index 0000000..b6e4605 --- /dev/null +++ b/src/lib/trollcall/api/message.ts @@ -0,0 +1,33 @@ +import { ClientClan, ServerClan } from "@/types/clan"; +import { ClientMessage } from "@/types/message"; +import { getSingleClan } from "../clan"; +import { ServerMessageToClientMessage } from "../convert/message"; +import { getSingleMessage } from "../message"; +import { ClanGET } from "./clan"; + +export async function MessageGET( + query: Partial<{ [key: string]: string | string[] }>, + recipientQuery?: string | null, + existingRecipient?: ServerClan +): Promise { + const to = + existingRecipient != null || query == null + ? existingRecipient + : await getSingleClan({ + name: recipientQuery + }); + if (to == null) return null; + const message = await getSingleMessage({ + ...query, + "to": to._id + }); + if (message == null) return null; + const from = await getSingleClan({ + _id: message.from + }); + if (from == null) return null; + const serverMessage = await ServerMessageToClientMessage(message); + // we know this is not null, as we passed in our own clan + serverMessage.from = (await ClanGET(null, from)) as ClientClan; + return serverMessage as ClientMessage; +} diff --git a/src/lib/trollcall/convert/message.ts b/src/lib/trollcall/convert/message.ts new file mode 100644 index 0000000..29b8639 --- /dev/null +++ b/src/lib/trollcall/convert/message.ts @@ -0,0 +1,26 @@ +import { SubmitMessage } from "@/types/client/message"; +import { ClientMessage, ServerMessage } from "@/types/message"; + +export function ServerMessageToClientMessage( + serverMessage: ServerMessage +): Omit { + let clientMessage: Omit = { + _id: serverMessage._id.toString(), + date: serverMessage.date, + subject: serverMessage.subject, + body: serverMessage.body + }; + + return clientMessage; +} + +export function SubmitMessageToServerMessage( + submitMessage: SubmitMessage +): Omit, "from">, "to"> { + let serverMessage: Omit, "from">, "to"> = { + date: new Date(Date.now()), + subject: submitMessage.subject, + body: submitMessage.body + }; + return serverMessage; +} diff --git a/src/lib/trollcall/message.ts b/src/lib/trollcall/message.ts new file mode 100644 index 0000000..790d104 --- /dev/null +++ b/src/lib/trollcall/message.ts @@ -0,0 +1,89 @@ +import { ServerMessage } from "@/types/message"; +import { Sort } from "mongodb"; +import { + Filter, + createOne, + cursorToArray, + deleteOne, + readMany, + readOne +} from "../db/crud"; + +const MessageSort: Sort = { _id: -1 }; + +/** + * A function that returns one ServerMessages from the database. + * @param query A partial Find query. Can contain an ID. + * @returns A ServerMessage. + */ + +export async function getSingleMessage( + query: Filter +): Promise { + const message = (await readOne("messages", query)) as ServerMessage | null; + return message; +} + +/** + * A function that returns many ServerMessages from the database using a FindCursor. + * @param query A partial Find query. Can contain an ID. + * @param func A function to run on every ServerMessages returned. Helps reduce loops. + * @returns An array of ServerMessages. + */ + +export async function getManyMessages( + query: Filter, + func?: (input: any) => T +): Promise<(Awaited | null)[]> { + const Message = (await cursorToArray( + readMany("messages", query, MessageSort), + func + )) as (Awaited | null)[]; + return Message; +} + +/** + * A function that returns many ServerMessages from the database using a FindCursor, limited by count. + * @param query A partial Find query. Can contain an ID. + * @param func A function to run on every ServerMessage returned. Helps reduce loops. + * @returns An array of ServerMessages. + */ + +export async function getManyPagedMessages( + query: Filter, + func?: (input: any) => T, + count: number = 5, + page: number = 0 +): Promise<(Awaited | null)[]> { + const find = readMany("messages", query, MessageSort) + .limit(count) + .skip(page * count); + const clan = (await cursorToArray(find, func)) as (Awaited | null)[]; + return clan; +} + +/** + * A function that puts one ServerMessage into the database. + * @param Message A ServerMessage. + * @returns A ServerMessage, or null, depending on if the operation succeeded. + */ + +export async function createMessage( + message: Omit +): Promise | null> { + const newMessage = await createOne("messages", message); + return newMessage.acknowledged ? message : null; +} + +/** + * A function that changes one database Message with the given params. + * @param Message A ServerMessage. + * @returns A ServerMessage, or null, depending on if the operation succeeded. + */ + +export async function deleteMessage( + message: Partial +): Promise { + const newMessage: ServerMessage = await deleteOne("messages", message); + return newMessage; +} diff --git a/src/pages/add/message/index.tsx b/src/pages/add/message/index.tsx new file mode 100644 index 0000000..d81df57 --- /dev/null +++ b/src/pages/add/message/index.tsx @@ -0,0 +1,34 @@ +import Box from "@/components/Box/Box"; +import MessageFormTemplate from "@/components/form/template/message"; +import NotSignedIn from "@/components/template/not-signed-in"; +import globals from "@/styles/global.module.css"; +import { ThemerGetSet } from "@/types/generics"; +import AuthContext from "@/utility/react/AuthContext"; +import { defaultTheme } from "@/utility/react/Themer"; +import { useRouter } from "next/router"; +import { useContext, useEffect, useState } from "react"; + +export default function AddMessage({ + themerVars: [theme, setTheme] +}: { + themerVars: ThemerGetSet; +}) { + const userCredentials = useContext(AuthContext); + const router = useRouter(); + // Prevent hydration error. Nav Auth section is a client-rendered element. + const [isClient, setIsClient] = useState(false); + useEffect(() => { + setIsClient(true); + setTheme(defaultTheme); + }, []); + return isClient && userCredentials.TROLLCALL_NAME != null ? ( + <> + + Send a message to someone. + + + + ) : ( + + ); +} diff --git a/src/pages/api/message/[clan]/.../[[...page]]/index.ts b/src/pages/api/message/[clan]/.../[[...page]]/index.ts new file mode 100644 index 0000000..f482542 --- /dev/null +++ b/src/pages/api/message/[clan]/.../[[...page]]/index.ts @@ -0,0 +1,51 @@ +import { ClanGET } from "@/lib/trollcall/api/clan"; +import { getSingleClan } from "@/lib/trollcall/clan"; +import { ServerMessageToClientMessage } from "@/lib/trollcall/convert/message"; +import { getManyPagedMessages } from "@/lib/trollcall/message"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { method, query } = req; + const page = query.page ? query.page[0] : 0; + if (method === "GET") { + const clan = await getSingleClan({ + name: query.clan + }); + if (clan == null) return res.status(404).end(); + const clientClan = await ClanGET(null, clan); + if (clientClan == null) return res.status(404).end(); + + const senders: { [key: string]: any } = {}; + + const messages = await getManyPagedMessages( + { + "to": clan._id + }, + async (message: any) => { + const thisMessage = await ServerMessageToClientMessage(message); + let clientFrom; + if (senders[message.from.toString()] != null) + clientFrom = senders[message.from.toString()]; + else { + const from = await getSingleClan({ + _id: message.from + }); + if (from == null) return res.status(404).end(); + clientFrom = await ClanGET(null, from); + if (clientFrom == null) return res.status(404).end(); + senders[message.from.toString()] = clientFrom; + } + thisMessage.from = clientFrom; + + return thisMessage; + }, + 5, + page + ); + if (messages == null) return res.status(404).end(); + res.json(messages); + } else return res.status(405).end(); +} diff --git a/src/pages/api/message/[clan]/index.ts b/src/pages/api/message/[clan]/index.ts new file mode 100644 index 0000000..158fa18 --- /dev/null +++ b/src/pages/api/message/[clan]/index.ts @@ -0,0 +1,28 @@ +import { getSingleClan } from "@/lib/trollcall/clan"; +import { deleteMessage } from "@/lib/trollcall/message"; +import { compareCredentials } from "@/lib/trollcall/perms"; +import { ObjectId } from "mongodb"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { body, method, cookies, query } = req; + if (method === "DELETE") { + if (query.clan == null || Array.isArray(query.clan)) + return res.status(400).end(); + const checkClan = await getSingleClan({ + name: cookies.TROLLCALL_NAME + }); + if (checkClan == null) return res.status(404).end(); + if (!(await compareCredentials(checkClan, cookies))) + return res.status(403).end(); + console.log(query.clan); + const newMessage = await deleteMessage({ + _id: new ObjectId(query.clan) + }); + if (newMessage == null) return res.status(503).end(); + res.json(newMessage); + } else return res.status(405).end(); +} diff --git a/src/pages/api/message/index.ts b/src/pages/api/message/index.ts new file mode 100644 index 0000000..a9428d1 --- /dev/null +++ b/src/pages/api/message/index.ts @@ -0,0 +1,44 @@ +import { getSingleClan } from "@/lib/trollcall/clan"; +import { SubmitMessageToServerMessage } from "@/lib/trollcall/convert/message"; +import { createMessage } from "@/lib/trollcall/message"; +import { compareCredentials } from "@/lib/trollcall/perms"; +import { SubmitMessageSchema } from "@/types/client/message"; +import { ServerMessage } from "@/types/message"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { body, method, cookies, query } = req; + if (method === "POST") { + let validatedMessage; + try { + validatedMessage = await SubmitMessageSchema.validate(body, { + stripUnknown: true + }); + } catch (err) { + return res.status(400).send(err); + } + const checkClan = await getSingleClan({ + name: cookies.TROLLCALL_NAME + }); + if (checkClan == null) return res.status(404).end(); + if (!(await compareCredentials(checkClan, cookies))) + return res.status(403).end(); + // ok, logged in now. check for "to" + const checkRecipient = await getSingleClan({ + name: validatedMessage.to + }); + if (checkRecipient == null) return res.status(400).end(); + // we are sure this object is full, so cast partial + let serverMessage = SubmitMessageToServerMessage( + validatedMessage + ) as Omit; + serverMessage.to = checkRecipient._id; + serverMessage.from = checkClan._id; + const newMessage = await createMessage(serverMessage); + if (newMessage == null) return res.status(503).end(); + res.json(newMessage); + } else return res.status(405).end(); +} diff --git a/src/pages/clan/[clan]/index.tsx b/src/pages/clan/[clan]/index.tsx index a0def1f..01a487b 100644 --- a/src/pages/clan/[clan]/index.tsx +++ b/src/pages/clan/[clan]/index.tsx @@ -1,5 +1,6 @@ import Box from "@/components/Box/Box"; import ClanCard from "@/components/cards/ClanCard/ClanCard"; +import MessageCard from "@/components/cards/MessageCard/MessageCard"; import TrollCard from "@/components/cards/TrollCard/TrollCard"; import TrollSkeleton from "@/components/cards/TrollCard/TrollSkeleton"; import { ClanGET } from "@/lib/trollcall/api/clan"; @@ -8,6 +9,7 @@ import globals from "@/styles/global.module.css"; import { Color3 } from "@/types/assist/color"; import { ClientClan } from "@/types/clan"; import { ThemerGetSet } from "@/types/generics"; +import { ClientMessage } from "@/types/message"; import { ClientTroll } from "@/types/troll"; import AuthContext from "@/utility/react/AuthContext"; import Conditional from "@/utility/react/Conditional"; @@ -31,7 +33,19 @@ export default function Index({ const [fetchedTrolls, setFetchedTrolls] = useState( null ); - + const [fetchedMessages, setFetchedMessages] = useState([]); + const [messagePageNum, setMessagePageNum] = useState(0); + const [noMore, setNoMore] = useState(false); + async function getMessage(page?: number) { + const res = await fetch( + page + ? "/api/message/" + clan.name + "/.../" + page + : "/api/message/" + clan.name + "/..." + ); + const json = await res.json(); + setFetchedMessages(fetchedMessages.concat(json)); + setNoMore(json.length < 5); + } async function getTroll(page?: number) { const res = await fetch( page @@ -43,6 +57,7 @@ export default function Index({ } useEffect(() => { getTroll(0); + getMessage(messagePageNum); const color = clan.color?.map(x => x / 255) as [number, number, number]; if (color != null) setTheme([ @@ -115,6 +130,35 @@ ${clan.css ?? ""} )) )} + {fetchedMessages.length > 0 ? ( + + {fetchedMessages.map((message: ClientMessage, idx) => ( + + ))} + + + ) : ( + <> + )} ); } diff --git a/src/pages/troll/[clan]/[troll]/index.tsx b/src/pages/troll/[clan]/[troll]/index.tsx index c6621f8..2c29292 100644 --- a/src/pages/troll/[clan]/[troll]/index.tsx +++ b/src/pages/troll/[clan]/[troll]/index.tsx @@ -73,7 +73,10 @@ export default function Index({

Policies

- + {policies.fanart === "yes" ? "check" : policies.fanart === "ask" @@ -83,7 +86,10 @@ export default function Index({ Fanart
- + {policies.fanartOthers === "yes" ? "check" : policies.fanartOthers === "ask" @@ -95,7 +101,10 @@ export default function Index({
- + {policies.fanfiction === "yes" ? "check" : policies.fanfiction === "ask" @@ -107,7 +116,10 @@ export default function Index({
- + {policies.kinning === "yes" ? "check" : policies.kinning === "ask" @@ -119,7 +131,10 @@ export default function Index({
- + {policies.shipping === "yes" ? "check" : policies.shipping === "ask" diff --git a/src/types/client/message.ts b/src/types/client/message.ts new file mode 100644 index 0000000..2909727 --- /dev/null +++ b/src/types/client/message.ts @@ -0,0 +1,8 @@ +import * as yup from "yup"; +export const SubmitMessageSchema = yup.object({ + subject: yup.string().min(0).max(500).ensure(), + body: yup.string().min(0).max(10000).ensure(), + to: yup.string().min(3).required() +}); + +export type SubmitMessage = yup.InferType; diff --git a/src/types/message.ts b/src/types/message.ts new file mode 100644 index 0000000..86495d5 --- /dev/null +++ b/src/types/message.ts @@ -0,0 +1,23 @@ +import * as yup from "yup"; +import { ObjectIdSchema } from "./assist/mongo"; +import { ClientClanSchema } from "./clan"; +import { SubmitMessageSchema } from "./client/message"; + +export const ServerMessageSchema = SubmitMessageSchema.shape({ + _id: ObjectIdSchema.required(), + date: yup.date(), + from: ObjectIdSchema.required(), + to: ObjectIdSchema.required() +}); + +export type ServerMessage = yup.InferType; + +export const ClientMessageSchema = yup.object({ + _id: yup.string().required(), + date: yup.date(), + from: ClientClanSchema.required(), + subject: yup.string().min(0).max(500).ensure(), + body: yup.string().min(0).max(10000).ensure() +}); + +export type ClientMessage = yup.InferType; diff --git a/tsconfig.json b/tsconfig.json index 6c21b25..d1b0b32 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,11 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "src/pages/api/message/[clan]/.../index.ts" + ], "exclude": ["node_modules"] }