remixed v0
This commit is contained in:
81
app/features/messages/components/conversation.tsx
Normal file
81
app/features/messages/components/conversation.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { useLoaderData, useParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { Direction } from "@prisma/client";
|
||||
|
||||
import NewMessageArea from "./new-message-area";
|
||||
import { formatDate, formatTime } from "~/features/core/helpers/date-formatter";
|
||||
import { type ConversationLoaderData } from "~/routes/__app/messages.$recipient";
|
||||
|
||||
export default function Conversation() {
|
||||
const params = useParams<{ recipient: string }>();
|
||||
const recipient = decodeURIComponent(params.recipient ?? "");
|
||||
const { conversation } = useLoaderData<ConversationLoaderData>();
|
||||
const messages = useMemo(() => conversation?.messages ?? [], [conversation?.messages]);
|
||||
const messagesListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesListRef.current?.querySelector("li:last-child")?.scrollIntoView();
|
||||
}, [messages, messagesListRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-6 p-6 pt-12 pb-16">
|
||||
<ul ref={messagesListRef}>
|
||||
{messages.length === 0 ? "empty state" : null}
|
||||
{messages.map((message, index) => {
|
||||
const isOutbound = message.direction === Direction.Outbound;
|
||||
const nextMessage = messages![index + 1];
|
||||
const previousMessage = messages![index - 1];
|
||||
const isNextMessageFromSameSender = message.from === nextMessage?.from;
|
||||
const isPreviousMessageFromSameSender = message.from === previousMessage?.from;
|
||||
|
||||
const messageSentAt = new Date(message.sentAt);
|
||||
const previousMessageSentAt = previousMessage ? new Date(previousMessage.sentAt) : null;
|
||||
const quarter = Math.floor(messageSentAt.getMinutes() / 15);
|
||||
const sameQuarter =
|
||||
previousMessage &&
|
||||
messageSentAt.getTime() - previousMessageSentAt!.getTime() < 15 * 60 * 1000 &&
|
||||
quarter === Math.floor(previousMessageSentAt!.getMinutes() / 15);
|
||||
const shouldGroupMessages = previousMessageSentAt && sameQuarter;
|
||||
return (
|
||||
<li key={message.id}>
|
||||
{(!isPreviousMessageFromSameSender || !shouldGroupMessages) && (
|
||||
<div className="flex py-2 space-x-1 text-xs justify-center">
|
||||
<strong>
|
||||
{formatDate(new Date(message.sentAt), {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})}
|
||||
</strong>
|
||||
<span>{formatTime(new Date(message.sentAt))}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
isNextMessageFromSameSender ? "pb-1" : "pb-2",
|
||||
isOutbound ? "text-right" : "text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-block whitespace-pre-line text-left w-[fit-content] p-2 rounded-lg text-white",
|
||||
isOutbound ? "bg-[#3194ff] rounded-br-none" : "bg-black rounded-bl-none",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<NewMessageArea recipient={recipient} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
41
app/features/messages/components/conversations-list.tsx
Normal file
41
app/features/messages/components/conversations-list.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { IoChevronForward } from "react-icons/io5";
|
||||
|
||||
import { formatRelativeDate } from "~/features/core/helpers/date-formatter";
|
||||
import PhoneInitLoader from "~/features/core/components/phone-init-loader";
|
||||
import EmptyMessages from "./empty-messages";
|
||||
import type { MessagesLoaderData } from "~/routes/__app/messages";
|
||||
|
||||
export default function ConversationsList() {
|
||||
const { conversations } = useLoaderData<MessagesLoaderData>();
|
||||
|
||||
if (!conversations) {
|
||||
// we're still importing messages from twilio
|
||||
return <PhoneInitLoader />;
|
||||
}
|
||||
|
||||
if (Object.keys(conversations).length === 0) {
|
||||
return <EmptyMessages />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y">
|
||||
{Object.values(conversations).map(({ recipient, formattedPhoneNumber, lastMessage }) => {
|
||||
return (
|
||||
<li key={`sms-conversation-${recipient}`} className="py-2 px-4">
|
||||
<Link to={`/messages/${recipient}`} className="flex flex-col">
|
||||
<div className="flex flex-row justify-between">
|
||||
<span className="font-medium">{formattedPhoneNumber ?? recipient}</span>
|
||||
<div className="text-gray-700 flex flex-row gap-x-1">
|
||||
{formatRelativeDate(lastMessage.sentAt)}
|
||||
<IoChevronForward className="w-4 h-4 my-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="line-clamp-2 text-gray-700">{lastMessage.content}</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
32
app/features/messages/components/empty-messages.tsx
Normal file
32
app/features/messages/components/empty-messages.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { IoCreateOutline, IoMailOutline } from "react-icons/io5";
|
||||
// import { useAtom } from "jotai";
|
||||
|
||||
// import { bottomSheetOpenAtom } from "../pages/messages";
|
||||
|
||||
export default function EmptyMessages() {
|
||||
// const setIsBottomSheetOpen = useAtom(bottomSheetOpenAtom)[1];
|
||||
// const openNewMessageArea = () => setIsBottomSheetOpen(true);
|
||||
const openNewMessageArea = () => void 0;
|
||||
|
||||
return (
|
||||
<div className="text-center my-auto">
|
||||
<IoMailOutline className="mx-auto h-12 w-12 text-gray-400" aria-hidden="true" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">You don't have any messages yet</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by sending a message
|
||||
<br />
|
||||
to someone you know.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={openNewMessageArea}
|
||||
>
|
||||
<IoCreateOutline className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
Type a new message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
88
app/features/messages/components/new-message-area.tsx
Normal file
88
app/features/messages/components/new-message-area.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import type { FunctionComponent } from "react";
|
||||
import { IoSend } from "react-icons/io5";
|
||||
import { type Message, Direction, MessageStatus } from "@prisma/client";
|
||||
import useSession from "~/features/core/hooks/use-session";
|
||||
|
||||
type Props = {
|
||||
recipient: string;
|
||||
onSend?: () => void;
|
||||
};
|
||||
|
||||
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
||||
const { currentOrganization, /*hasOngoingSubscription*/ } = useSession();
|
||||
// const phoneNumber = useCurrentPhoneNumber();
|
||||
// const sendMessageMutation = useMutation(sendMessage)[0];
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
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"
|
||||
>
|
||||
<textarea
|
||||
name="content"
|
||||
className="resize-none flex-1"
|
||||
autoCapitalize="sentences"
|
||||
autoCorrect="on"
|
||||
placeholder="Text message"
|
||||
rows={1}
|
||||
spellCheck
|
||||
required
|
||||
/>
|
||||
<button type="submit">
|
||||
<IoSend className="h-8 w-8 pl-1 pr-2" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
56
app/features/messages/loaders/messages.ts
Normal file
56
app/features/messages/loaders/messages.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { type Message, Prisma, Direction, SubscriptionStatus } from "@prisma/client";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
|
||||
export type MessagesLoaderData = {
|
||||
user: {
|
||||
hasFilledTwilioCredentials: boolean;
|
||||
hasPhoneNumber: boolean;
|
||||
};
|
||||
conversations: Record<string, Conversation> | undefined;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
recipient: string;
|
||||
formattedPhoneNumber: string;
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
const loader: LoaderFunction = async ({ request }) => {
|
||||
const { id, organizations } = await requireLoggedIn(request);
|
||||
const user = await db.user.findFirst({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
memberships: {
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
subscriptions: {
|
||||
where: {
|
||||
OR: [
|
||||
{ status: { not: SubscriptionStatus.deleted } },
|
||||
{
|
||||
status: SubscriptionStatus.deleted,
|
||||
cancellationEffectiveDate: { gt: new Date() },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { lastEventTime: Prisma.SortOrder.desc },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const organization = user!.memberships[0]!.organization;
|
||||
// const hasFilledTwilioCredentials = Boolean(organization?.twilioAccountSid && organization?.twilioAuthToken);
|
||||
};
|
||||
|
||||
export default loader;
|
Reference in New Issue
Block a user