Messaging update (v0.2)

This commit is contained in:
MeowcaTheoRange 2023-09-25 12:45:33 -05:00
parent 011e71e715
commit e7f94445da
19 changed files with 752 additions and 10 deletions

View file

@ -73,6 +73,14 @@ export default function Nav(elementProps: AnyObject) {
userCredentials.TROLLCALL_NAME != null &&
userCredentials.TROLLCALL_NAME != "" ? (
<>
<span className={globals.icon}>
<Link
className={globals.link}
href="/add/message/"
>
send
</Link>
</span>
<span className={globals.icon}>
<Link
href={`/clan/${userCredentials.TROLLCALL_NAME}`}

View file

@ -0,0 +1,53 @@
.MessageCard {
/* min-width: 600px; */
width: 100%;
align-items: stretch;
}
.MessageCard.Skeleton {
opacity: 0.25;
user-select: none;
}
.MessageCard .horizontal {
display: grid;
grid-template-columns: auto 1fr;
justify-content: start;
gap: 16px;
padding: 8px 8px;
box-sizing: border-box;
}
.MessageCard .horizontal .horizontalLeft {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 48px;
overflow: hidden;
width: 96px;
height: auto;
background-color: var(--pri-fg);
}
.MessageCard .horizontal .horizontalLeft img {
width: 100%;
height: 100%;
object-fit: cover;
}
.MessageCard .horizontal .horizontalRight {
display: flex;
flex-direction: column;
gap: 4px;
/* overflow: hidden; */
width: 100%;
}
.MessageCard .horizontal .horizontalRight > * {
width: 100%;
text-overflow: ellipsis;
/* overflow: hidden; */
}

View file

@ -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 (
<Box
properties={{
class: styles.MessageCard
}}
>
<div className={styles.horizontal}>
<Conditional condition={message.from.pfp != null}>
<div className={styles.horizontalLeft}>
<img
src={message.from.pfp as string}
width="96"
height="96"
alt=""
></img>
</div>
</Conditional>
<div className={styles.horizontalRight}>
<Link
href={`/clan/${message.from.name}`}
className={globals.link}
>
<p className={globals.title}>
{message.from.displayName ?? message.from.name}
</p>
</Link>
{/* <p className={globals.text}>
to {recipient.displayName ?? recipient.name}
</p> */}
<hr className={globals.sep} />
<Conditional condition={message.subject != ""}>
<p className={globals.titleSmall}>{message.subject}</p>
</Conditional>
<Conditional condition={message.body != ""}>
<p className={globals.text}>{message.body}</p>
</Conditional>
</div>
</div>
<div className={globals.horizontalListLeft}>
<Conditional
condition={
message.from.name === userCredentials.TROLLCALL_NAME ||
recipient.name === userCredentials.TROLLCALL_NAME
}
>
<button
className={globals.button}
onClick={() => {
fetch("/api/message/" + message._id, {
method: "DELETE"
}).then(() => window.location.reload());
}}
>
Delete
</button>
</Conditional>
<span className={globals.text}>{message._id}</span>
</div>
</Box>
);
}

View file

@ -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 (
<Box
properties={{
class: styles.MessageCard + " " + styles.Skeleton
}}
>
<div className={styles.horizontal}>
<div className={styles.horizontalRight}>
<p className={globals.title}>Tim Sweeney</p>
{/* <p className={globals.text}>
to {recipient.displayName ?? recipient.name}
</p> */}
<hr className={globals.sep} />
<p className={globals.titleSmall}>12%</p>
<p className={globals.text}>
hey did you know we only take 12%? did you know? did
you? hey did you know we
</p>
</div>
</div>
</Box>
);
}

View file

@ -101,8 +101,8 @@ export default function TrollCard({
<hr className={globals.invisep} />
<Conditional
condition={
troll.class != null &&
troll.gender != null &&
troll.class != null ||
troll.gender != null ||
troll.species != null
}
>

View file

@ -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 (
<Formik
initialValues={
initialValues ??
({
to: "",
body: "",
subject: ""
} as SubmitMessage)
}
validationSchema={SubmitMessageSchema}
onSubmit={async (values, { setSubmitting, setErrors, setFieldError }) => {
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 }) => (
<Form className={form_globals.FlexNormalizer}>
<Box>
<div className={globals.verticalListTop}>
<div className={form_globals.verticalListCrunch}>
<p className={globals.titleSmall}>Recipient</p>
<span className={globals.text}>
Who will recieve this message. Type the name of the user (the part you will find in their URL, not the display name.)
</span>
</div>
<div className={globals.verticalListTop}>
<div className={globals.horizontalListLeft}>
<Field
type="text"
name="to"
placeholder="jim"
className={`
${form_globals.textLikeInput}
${form_globals.textLikeInputTight}
`}
/>
</div>
<ErrorMessage
name="to"
render={ErrorHandler}
/>
</div>
</div>
<hr className={globals.invisep} />
<div className={globals.verticalListTop}>
<div className={form_globals.verticalListCrunch}>
<p className={globals.titleSmall}>Subject</p>
<span className={globals.text}>What your message is about. Can be left blank.</span>
</div>
<div className={globals.verticalListTop}>
<div className={globals.horizontalListLeft}>
<Field
type="text"
name="subject"
placeholder="Hello, world!"
className={`
${form_globals.textLikeInput}
${form_globals.textLikeInputTight}
`}
/>
</div>
<ErrorMessage
name="subject"
render={ErrorHandler}
/>
</div>
</div>
<hr className={globals.invisep} />
<div className={globals.verticalListTop}>
<div className={form_globals.verticalListCrunch}>
<p className={globals.titleSmall}>Body</p>
<span className={globals.text}>The main text of your message.</span>
</div>
<div className={globals.verticalListTop}>
<Field
as="textarea"
name="body"
placeholder="body"
className={`
${form_globals.textLikeInput}
${form_globals.textLikeInputMultiline}
`}
/>
<span
className={`${globals.text} ${values.body.length > 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"}
</span>
<ErrorMessage
name="body"
render={ErrorHandler}
/>
</div>
</div>
</Box>
<Box properties={{ title: { text: "Form Settings" } }}>
<div className={globals.horizontalListLeft}>
<button
type="reset"
className={globals.button}
disabled={isSubmitting}
// onClick={() => resetForm()}
>
Reset Form
</button>
<button
type="submit"
className={globals.button}
disabled={isSubmitting}
// onClick={submitForm}
>
Submit Form
</button>
</div>
<div className={globals.horizontalListLeft}>{submitError != null && submitError.length > 0 ? ErrorHandler(submitError) : ""}</div>
<details>
<summary>Advanced stuff</summary>
<div className={globals.verticalListTop}>
<details>
<summary>Values (Advanced)</summary>
<p className={`${globals.mono} ${globals.blockTextKeepTabs}`}>{JSON.stringify(values, null, 2)}</p>
</details>
<details>
<summary>Errors (Advanced)</summary>
<p className={`${globals.mono} ${globals.blockTextKeepTabs}`}>{JSON.stringify(errors, null, 2)}</p>
</details>
<p className={globals.text}>If something is wrong, make sure to send these with your report!</p>
</div>
</details>
</Box>
</Form>
)}
</Formik>
);
}

View file

@ -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<T>(

View file

@ -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<ClientMessage | null> {
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;
}

View file

@ -0,0 +1,26 @@
import { SubmitMessage } from "@/types/client/message";
import { ClientMessage, ServerMessage } from "@/types/message";
export function ServerMessageToClientMessage(
serverMessage: ServerMessage
): Omit<ClientMessage, "from"> {
let clientMessage: Omit<ClientMessage, "from"> = {
_id: serverMessage._id.toString(),
date: serverMessage.date,
subject: serverMessage.subject,
body: serverMessage.body
};
return clientMessage;
}
export function SubmitMessageToServerMessage(
submitMessage: SubmitMessage
): Omit<Omit<Omit<ServerMessage, "_id">, "from">, "to"> {
let serverMessage: Omit<Omit<Omit<ServerMessage, "_id">, "from">, "to"> = {
date: new Date(Date.now()),
subject: submitMessage.subject,
body: submitMessage.body
};
return serverMessage;
}

View file

@ -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<ServerMessage>
): Promise<ServerMessage | null> {
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<T>(
query: Filter<ServerMessage>,
func?: (input: any) => T
): Promise<(Awaited<T> | null)[]> {
const Message = (await cursorToArray(
readMany("messages", query, MessageSort),
func
)) as (Awaited<T> | 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<T>(
query: Filter<ServerMessage>,
func?: (input: any) => T,
count: number = 5,
page: number = 0
): Promise<(Awaited<T> | null)[]> {
const find = readMany("messages", query, MessageSort)
.limit(count)
.skip(page * count);
const clan = (await cursorToArray(find, func)) as (Awaited<T> | 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<ServerMessage, "_id">
): Promise<Omit<ServerMessage, "_id"> | 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<ServerMessage>
): Promise<ServerMessage> {
const newMessage: ServerMessage = await deleteOne("messages", message);
return newMessage;
}

View file

@ -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 ? (
<>
<Box properties={{ title: { text: "Add Message" } }}>
<span className={globals.text}>Send a message to someone.</span>
</Box>
<MessageFormTemplate router={router} />
</>
) : (
<NotSignedIn />
);
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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, "_id">;
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();
}

View file

@ -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<ClientTroll[] | null>(
null
);
const [fetchedMessages, setFetchedMessages] = useState<ClientMessage[]>([]);
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 ?? ""}
))
)}
</Box>
{fetchedMessages.length > 0 ? (
<Box
properties={{
title: {
text: "Messages",
small: true
}
}}
>
{fetchedMessages.map((message: ClientMessage, idx) => (
<MessageCard
message={message}
recipient={clan}
key={idx + "clan"}
/>
))}
<button
className={globals.button}
onClick={() => {
setMessagePageNum(messagePageNum + 1);
}}
disabled={noMore}
>
Load more
</button>
</Box>
) : (
<></>
)}
</>
);
}

View file

@ -73,7 +73,10 @@ export default function Index({
<p className={globals.titleSmall}>Policies</p>
<div className={globals.verticalListTop}>
<div className={globals.iconText}>
<span className={globals.icon}>
<span
className={globals.icon}
title={policies.fanart}
>
{policies.fanart === "yes"
? "check"
: policies.fanart === "ask"
@ -83,7 +86,10 @@ export default function Index({
<span className={globals.text}>Fanart</span>
</div>
<div className={globals.iconText}>
<span className={globals.icon}>
<span
className={globals.icon}
title={policies.fanartOthers}
>
{policies.fanartOthers === "yes"
? "check"
: policies.fanartOthers === "ask"
@ -95,7 +101,10 @@ export default function Index({
</span>
</div>
<div className={globals.iconText}>
<span className={globals.icon}>
<span
className={globals.icon}
title={policies.fanfiction}
>
{policies.fanfiction === "yes"
? "check"
: policies.fanfiction === "ask"
@ -107,7 +116,10 @@ export default function Index({
</span>
</div>
<div className={globals.iconText}>
<span className={globals.icon}>
<span
className={globals.icon}
title={policies.kinning}
>
{policies.kinning === "yes"
? "check"
: policies.kinning === "ask"
@ -119,7 +131,10 @@ export default function Index({
</span>
</div>
<div className={globals.iconText}>
<span className={globals.icon}>
<span
className={globals.icon}
title={policies.shipping}
>
{policies.shipping === "yes"
? "check"
: policies.shipping === "ask"

View file

@ -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<typeof SubmitMessageSchema>;

23
src/types/message.ts Normal file
View file

@ -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<typeof ServerMessageSchema>;
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<typeof ClientMessageSchema>;

View file

@ -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"]
}