send message to recipient

This commit is contained in:
m5r 2022-05-20 00:55:02 +02:00
parent 29f405290c
commit 5bf885c060
8 changed files with 147 additions and 101 deletions

View File

@ -6,8 +6,8 @@ import clsx from "clsx";
export default function Footer() { export default function Footer() {
return ( return (
<footer <footer
className="grid grid-cols-4 bg-[#F7F7F7] border-t border-gray-400 border-opacity-25 py-3 z-10" className="grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
style={{ flex: "0 0 50px" }} // className="grid grid-cols-4 border-t border-gray-400 border-opacity-25 py-3 z-10 backdrop-blur"
> >
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} /> <FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
<FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} /> <FooterLink label="Keypad" path="/keypad" icon={<IoKeypad className="w-6 h-6" />} />

View File

@ -1,6 +1,6 @@
import { useMatches } from "@remix-run/react"; import { useMatches } from "@remix-run/react";
import type { SessionOrganization, SessionUser } from "~/utils/auth.server"; import type { SessionData } from "~/utils/auth.server";
export default function useSession() { export default function useSession() {
const matches = useMatches(); const matches = useMatches();
@ -9,5 +9,5 @@ export default function useSession() {
throw new Error("useSession hook called outside _app route"); throw new Error("useSession hook called outside _app route");
} }
return __appRoute.data as SessionUser & { currentOrganization: SessionOrganization }; return __appRoute.data as SessionData;
} }

View File

@ -0,0 +1,61 @@
import { type ActionFunction } from "@remix-run/node";
import { json } from "superjson-remix";
import db from "~/utils/db.server";
import { requireLoggedIn } from "~/utils/auth.server";
import getTwilioClient, { translateMessageDirection, translateMessageStatus } from "~/utils/twilio.server";
export type NewMessageActionData = {};
const action: ActionFunction = async ({ params, request }) => {
const user = await requireLoggedIn(request);
const organization = user.organizations[0];
const phoneNumber = await db.phoneNumber.findUnique({
where: { organizationId_isCurrent: { organizationId: user.organizations[0].id, isCurrent: true } },
});
const recipient = decodeURIComponent(params.recipient ?? "");
const formData = Object.fromEntries(await request.formData());
const { twilioAccountSid, twilioSubAccountSid } = organization;
// const twilioClient = getTwilioClient({ twilioSubAccountSid, twilioAccountSid });
const twilioClient = getTwilioClient({ twilioSubAccountSid: twilioAccountSid, twilioAccountSid });
try {
console.log({ twilioAccountSid, twilioSubAccountSid });
console.log({
body: formData.content.toString(),
to: recipient,
from: phoneNumber!.number,
});
const message = await twilioClient.messages.create({
body: formData.content.toString(),
to: recipient,
from: phoneNumber!.number,
});
await db.message.create({
data: {
phoneNumberId: phoneNumber!.id,
id: message.sid,
to: message.to,
from: message.from,
status: translateMessageStatus(message.status),
direction: translateMessageDirection(message.direction),
sentAt: new Date(message.dateCreated),
content: message.body,
},
});
} catch (error: any) {
// TODO: handle twilio error
console.log(error.code); // 21211
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
console.log(JSON.stringify(error));
throw error;
/*await db.message.update({
where: { id },
data: { status: MessageStatus.Error /!*errorMessage: "Reason: failed because of"*!/ },
});*/
}
return json<NewMessageActionData>({});
};
export default action;

View File

@ -1,5 +1,5 @@
import { Suspense, useEffect, useMemo, useRef } from "react"; import { useEffect, useRef } from "react";
import { useParams } from "@remix-run/react"; import { useParams, useTransition } from "@remix-run/react";
import { useLoaderData } from "superjson-remix"; import { useLoaderData } from "superjson-remix";
import clsx from "clsx"; import clsx from "clsx";
import { Direction } from "@prisma/client"; import { Direction } from "@prisma/client";
@ -7,14 +7,31 @@ import { Direction } from "@prisma/client";
import NewMessageArea from "./new-message-area"; import NewMessageArea from "./new-message-area";
import { formatDate, formatTime } from "~/features/core/helpers/date-formatter"; import { formatDate, formatTime } from "~/features/core/helpers/date-formatter";
import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient"; import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient";
import useSession from "~/features/core/hooks/use-session";
export default function Conversation() { export default function Conversation() {
const { currentPhoneNumber } = useSession();
const params = useParams<{ recipient: string }>(); const params = useParams<{ recipient: string }>();
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
const { conversation } = useLoaderData<ConversationLoaderData>(); const { conversation } = useLoaderData<ConversationLoaderData>();
const messages = useMemo(() => conversation?.messages ?? [], [conversation?.messages]); const transition = useTransition();
const messagesListRef = useRef<HTMLUListElement>(null); const messagesListRef = useRef<HTMLUListElement>(null);
const messages = conversation.messages;
if (transition.submission) {
messages.push({
id: "temp",
phoneNumberId: currentPhoneNumber.id,
from: currentPhoneNumber.number,
to: recipient,
sentAt: new Date(),
direction: Direction.Outbound,
status: "Queued",
content: transition.submission.formData.get("content")!.toString()
})
}
useEffect(() => { useEffect(() => {
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView(); messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
}, [messages, messagesListRef]); }, [messages, messagesListRef]);
@ -74,7 +91,7 @@ export default function Conversation() {
})} })}
</ul> </ul>
</div> </div>
<NewMessageArea recipient={recipient} /> <NewMessageArea />
</> </>
); );
} }

View File

@ -1,68 +1,34 @@
import type { FunctionComponent } from "react"; import { useEffect, useRef } from "react";
import { Form, useTransition } from "@remix-run/react";
import { IoSend } from "react-icons/io5"; import { IoSend } from "react-icons/io5";
import { type Message, Direction, MessageStatus } from "@prisma/client";
import useSession from "~/features/core/hooks/use-session";
type Props = { function NewMessageArea() {
recipient: string; const transition = useTransition();
onSend?: () => void; const formRef = useRef<HTMLFormElement>(null);
}; const textFieldRef = useRef<HTMLTextAreaElement>(null);
const isSendingMessage = transition.state === "submitting";
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => { useEffect(() => {
const { currentOrganization, /*hasOngoingSubscription*/ } = useSession(); if (isSendingMessage) {
// const phoneNumber = useCurrentPhoneNumber(); formRef.current?.reset();
// const sendMessageMutation = useMutation(sendMessage)[0]; textFieldRef.current?.focus();
const onSubmit = async () => {
/*const id = uuidv4();
const message: Message = {
id,
organizationId: organization!.id,
phoneNumberId: phoneNumber!.id,
from: phoneNumber!.number,
to: recipient,
content: hasOngoingSubscription
? content
: content + "\n\nSent from Shellphone (https://www.shellphone.app)",
direction: Direction.Outbound,
status: MessageStatus.Queued,
sentAt: new Date(),
};*/
/*await setConversationsQueryData(
(conversations) => {
const nextConversations = { ...conversations };
if (!nextConversations[recipient]) {
nextConversations[recipient] = {
recipient,
formattedPhoneNumber: recipient,
messages: [],
};
} }
}, [isSendingMessage]);
nextConversations[recipient]!.messages = [...nextConversations[recipient]!.messages, message];
return Object.fromEntries(
Object.entries(nextConversations).sort(
([, a], [, b]) =>
b.messages[b.messages.length - 1]!.sentAt.getTime() -
a.messages[a.messages.length - 1]!.sentAt.getTime(),
),
);
},
{ refetch: false },
);*/
// setValue("content", "");
// onSend?.();
};
return ( return (
<form <Form
onSubmit={onSubmit} ref={formRef}
className="absolute bottom-0 w-screen backdrop-filter backdrop-blur-xl bg-white bg-opacity-75 border-t flex flex-row h-16 p-2 pr-0" method="post"
className="absolute bottom-0 w-screen backdrop-filter backdrop-blur-xl bg-white bg-opacity-75 border-t flex flex-row h-14 mb-16 p-2 pr-0"
replace
> >
<textarea <textarea
ref={textFieldRef}
name="content" name="content"
className="resize-none flex-1" className="resize-none rounded-full flex-1"
style={{
scrollbarWidth: "none",
}}
autoCapitalize="sentences" autoCapitalize="sentences"
autoCorrect="on" autoCorrect="on"
placeholder="Text message" placeholder="Text message"
@ -73,16 +39,8 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
<button type="submit"> <button type="submit">
<IoSend className="h-8 w-8 pl-1 pr-2" /> <IoSend className="h-8 w-8 pl-1 pr-2" />
</button> </button>
</form> </Form>
); );
}; }
export default NewMessageArea; export default NewMessageArea;
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@ -5,7 +5,7 @@ import { type SessionData, type SessionOrganization, requireLoggedIn } from "~/u
import Footer from "~/features/core/components/footer"; import Footer from "~/features/core/components/footer";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
export type AppLoaderData = SessionData export type AppLoaderData = SessionData;
export const loader: LoaderFunction = async ({ request }) => { export const loader: LoaderFunction = async ({ request }) => {
const user = await requireLoggedIn(request); const user = await requireLoggedIn(request);
@ -16,21 +16,27 @@ export const loader:LoaderFunction = async ({ request }) => {
where: { userId: user.id }, where: { userId: user.id },
select: { role: true }, select: { role: true },
}, },
phoneNumbers: {
where: { isCurrent: true },
select: { id: true, number: true },
},
}, },
}); });
const currentOrganization: SessionOrganization = { const currentOrganization: SessionOrganization = {
id: organization!.id, id: organization!.id,
twilioAccountSid: organization!.twilioAccountSid, twilioAccountSid: organization!.twilioAccountSid,
twilioSubAccountSid: organization!.twilioSubAccountSid,
role: organization!.memberships[0].role, role: organization!.memberships[0].role,
}; };
const currentPhoneNumber = organization!.phoneNumbers[0];
return json<AppLoaderData>({ ...user, currentOrganization }); return json<AppLoaderData>({ ...user, currentOrganization, currentPhoneNumber });
} };
export default function __App() { export default function __App() {
const hideFooter = false; const hideFooter = false;
const matches = useMatches(); const matches = useMatches();
// matches[0].handle // matches[0].handle.hideFooter
// console.log("matches", matches); // console.log("matches", matches);
return ( return (
@ -55,9 +61,7 @@ export function CatchBoundary() {
<div className="h-full w-full overflow-hidden fixed bg-gray-100"> <div className="h-full w-full overflow-hidden fixed bg-gray-100">
<div className="flex flex-col w-full h-full"> <div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto"> <div className="flex flex-col flex-1 w-full overflow-y-auto">
<main className="flex flex-col flex-1 my-0 h-full"> <main className="flex flex-col flex-1 my-0 h-full">{caught.status}</main>
{caught.status}
</main>
</div> </div>
<Footer /> <Footer />
</div> </div>

View File

@ -1,15 +1,16 @@
import { Suspense } from "react"; import { Suspense } from "react";
import type { LoaderFunction, MetaFunction } from "@remix-run/node"; import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { useNavigate, useParams } from "@remix-run/react"; import { Link, useNavigate, useParams } from "@remix-run/react";
import { json, useLoaderData } from "superjson-remix"; import { json, useLoaderData } from "superjson-remix";
import { IoCall, IoChevronBack, IoInformationCircle } from "react-icons/io5"; import { IoCall, IoChevronBack } from "react-icons/io5";
import { parsePhoneNumber } from "awesome-phonenumber";
import { type Message, Prisma } from "@prisma/client"; import { type Message, Prisma } from "@prisma/client";
import Conversation from "~/features/messages/components/conversation"; import Conversation from "~/features/messages/components/conversation";
import { getSeoMeta } from "~/utils/seo"; import { getSeoMeta } from "~/utils/seo";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import { parsePhoneNumber } from "awesome-phonenumber";
import { requireLoggedIn } from "~/utils/auth.server"; import { requireLoggedIn } from "~/utils/auth.server";
import newMessageAction from "~/features/messages/actions/messages.$recipient";
export const meta: MetaFunction = ({ params }) => { export const meta: MetaFunction = ({ params }) => {
const recipient = decodeURIComponent(params.recipient ?? ""); const recipient = decodeURIComponent(params.recipient ?? "");
@ -21,6 +22,8 @@ export const meta: MetaFunction = ({ params }) => {
}; };
}; };
export const action = newMessageAction;
type ConversationType = { type ConversationType = {
recipient: string; recipient: string;
formattedPhoneNumber: string; formattedPhoneNumber: string;
@ -70,20 +73,21 @@ export default function ConversationPage() {
const { conversation } = useLoaderData<ConversationLoaderData>(); const { conversation } = useLoaderData<ConversationLoaderData>();
return ( return (
<> <section className="h-full">
<header className="absolute top-0 w-screen h-12 backdrop-filter backdrop-blur-sm bg-white bg-opacity-75 border-b grid grid-cols-3 items-center"> <header className="absolute top-0 w-screen h-12 backdrop-filter backdrop-blur-sm bg-white bg-opacity-75 border-b items-center flex justify-between">
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={() => navigate(-1)}> <span className="pl-2 cursor-pointer" onClick={() => navigate(-1)}>
<IoChevronBack className="h-8 w-8" /> <IoChevronBack className="h-6 w-6" />
</span>
<strong className="col-span-1">{conversation?.formattedPhoneNumber ?? recipient}</strong>
<span className="col-span-1 flex justify-end space-x-4 pr-2">
<IoCall className="h-8 w-8" />
<IoInformationCircle className="h-8 w-8" />
</span> </span>
<strong className="absolute right-0 left-0 text-center pointer-events-none">
{conversation?.formattedPhoneNumber ?? recipient}
</strong>
<Link to={`/outgoing-call/${encodeURI(recipient)}`} className="pr-2">
<IoCall className="h-6 w-6" />
</Link>
</header> </header>
{/*<Suspense fallback={<div className="pt-12">Loading messages with {recipient}</div>}>*/} {/*<Suspense fallback={<div className="pt-12">Loading messages with {recipient}</div>}>*/}
<Conversation /> <Conversation />
{/*</Suspense>*/} {/*</Suspense>*/}
</> </section>
); );
} }

View File

@ -1,7 +1,7 @@
import { redirect, type Session } from "@remix-run/node"; import { redirect, type Session } from "@remix-run/node";
import type { FormStrategyVerifyParams } from "remix-auth-form"; import type { FormStrategyVerifyParams } from "remix-auth-form";
import SecurePassword from "secure-password"; import SecurePassword from "secure-password";
import type { MembershipRole, Organization, User } from "@prisma/client"; import type { MembershipRole, Organization, PhoneNumber, User } from "@prisma/client";
import db from "./db.server"; import db from "./db.server";
import logger from "./logger.server"; import logger from "./logger.server";
@ -12,10 +12,11 @@ import { commitSession, destroySession, getSession } from "./session.server";
export type SessionOrganization = Pick<Organization, "id" | "twilioSubAccountSid" | "twilioAccountSid"> & { export type SessionOrganization = Pick<Organization, "id" | "twilioSubAccountSid" | "twilioAccountSid"> & {
role: MembershipRole; role: MembershipRole;
}; };
export type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
export type SessionUser = Omit<User, "hashedPassword"> & { export type SessionUser = Omit<User, "hashedPassword"> & {
organizations: SessionOrganization[]; organizations: SessionOrganization[];
}; };
export type SessionData = SessionUser & { currentOrganization: SessionOrganization }; export type SessionData = SessionUser & { currentOrganization: SessionOrganization; currentPhoneNumber: SessionPhoneNumber };
const SP = new SecurePassword(); const SP = new SecurePassword();
@ -136,10 +137,11 @@ export async function requireLoggedOut(request: Request) {
export async function requireLoggedIn(request: Request) { export async function requireLoggedIn(request: Request) {
const user = await authenticator.isAuthenticated(request); const user = await authenticator.isAuthenticated(request);
if (!user) {
const signInUrl = "/sign-in"; const signInUrl = "/sign-in";
const redirectTo = buildRedirectTo(new URL(request.url)); const redirectTo = buildRedirectTo(new URL(request.url));
const searchParams = new URLSearchParams({ redirectTo }); const searchParams = new URLSearchParams({ redirectTo });
if (!user) {
throw redirect(`${signInUrl}?${searchParams.toString()}`, { throw redirect(`${signInUrl}?${searchParams.toString()}`, {
headers: { "Set-Cookie": await destroySession(await getSession(request)) }, headers: { "Set-Cookie": await destroySession(await getSession(request)) },
}); });