local cache

This commit is contained in:
m5r 2021-07-23 17:40:08 +08:00
parent 0760fa8f41
commit 4aa646ab43
19 changed files with 528 additions and 523 deletions

View File

@ -26,7 +26,7 @@ type Form = {
};
const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
const { userProfile } = useUser();
const { customer } = useUser();
const { subscribe, changePlan } = useSubscription();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(
false,
@ -78,8 +78,8 @@ const BillingPlans: FunctionComponent<Props> = ({ activePlanId = FREE.id }) => {
return;
}
const email = userProfile!.email!;
const userId = userProfile!.id;
const email = customer!.email!;
const userId = customer!.id;
const selectedPlanId = selectedPlan.id;
const isMovingToPaidPlan =

View File

@ -0,0 +1,158 @@
import type { FunctionComponent } from "react";
import { useEffect } from "react";
import { useRouter } from "next/router";
import { useAtom } from "jotai";
import { conversationsAtom, customerAtom, customerPhoneNumberAtom, messagesAtom, phoneCallsAtom } from "../state";
import supabase from "../supabase/client";
import type { Customer } from "../database/customer";
import { PhoneNumber } from "../database/phone-number";
import { Message } from "../database/message";
import { PhoneCall } from "../database/phone-call";
type Props = {}
const ConnectedLayout: FunctionComponent<Props> = ({
children,
}) => {
useRequireOnboarding();
const { isInitialized } = useInitializeState();
if (!isInitialized) {
return (
<>Loading...</>
);
}
return (
<>
{children}
</>
);
};
export default ConnectedLayout;
function useRequireOnboarding() {
const router = useRouter();
const [customer] = useAtom(customerAtom);
useEffect(() => {
(async () => {
if (!customer) {
// still loading
return;
}
if (!customer.accountSid || !customer.authToken) {
return router.push("/welcome/step-two");
}
const phoneNumberResponse = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
.eq("customerId", customer.id)
.single();
if (phoneNumberResponse.error) {
return router.push("/welcome/step-three");
}
})();
}, [customer, router]);
}
function useInitializeState() {
useInitializeCustomer();
useInitializeMessages();
useInitializePhoneCalls();
const customer = useAtom(customerAtom)[0];
const messages = useAtom(messagesAtom)[0];
const phoneCalls = useAtom(phoneCallsAtom)[0];
return {
isInitialized: customer !== null && messages !== null && phoneCalls !== null,
};
}
function useInitializeCustomer() {
const router = useRouter();
const setCustomer = useAtom(customerAtom)[1];
const setCustomerPhoneNumber = useAtom(customerPhoneNumberAtom)[1];
useEffect(() => {
(async () => {
const redirectTo = `/auth/sign-in?redirectTo=${router.pathname}`;
// TODO: also redirect when no cookie
try {
await supabase.auth.refreshSession();
} catch (error) {
console.error("session error", error);
return router.push(redirectTo);
}
const user = supabase.auth.user();
if (!user) {
return router.push(redirectTo);
}
const customerId = user.id;
const customerResponse = await supabase
.from<Customer>("customer")
.select("*")
.eq("id", customerId)
.single();
if (customerResponse.error) throw customerResponse.error;
const customer = customerResponse.data;
setCustomer(customer);
const customerPhoneNumberResponse = await supabase
.from<PhoneNumber>("phone-number")
.select("*")
.eq("customerId", customerId)
.single();
if (customerPhoneNumberResponse.error) throw customerPhoneNumberResponse.error;
setCustomerPhoneNumber(customerPhoneNumberResponse.data);
})();
}, []);
}
function useInitializeMessages() {
const customer = useAtom(customerAtom)[0];
const setMessages = useAtom(messagesAtom)[1];
useEffect(() => {
(async () => {
if (!customer) {
return;
}
const messagesResponse = await supabase
.from<Message>("message")
.select("*")
.eq("customerId", customer.id);
if (messagesResponse.error) throw messagesResponse.error;
setMessages(messagesResponse.data);
})();
}, [customer, setMessages]);
}
function useInitializePhoneCalls() {
const customer = useAtom(customerAtom)[0];
const setPhoneCalls = useAtom(phoneCallsAtom)[1];
useEffect(() => {
(async () => {
if (!customer) {
return;
}
const phoneCallsResponse = await supabase
.from<PhoneCall>("phone-call")
.select("*")
.eq("customerId", customer.id);
if (phoneCallsResponse.error) throw phoneCallsResponse.error;
setPhoneCalls(phoneCallsResponse.data);
})();
}, [customer, setPhoneCalls]);
}

View File

@ -8,7 +8,7 @@ import Avatar from "../avatar";
import useUser from "../../hooks/use-user";
export default function Header() {
const { userProfile } = useUser();
const { customer } = useUser();
return (
<header
@ -35,7 +35,7 @@ export default function Header() {
aria-haspopup="true"
>
<Avatar
name={userProfile?.email ?? "FSS"}
name={customer?.email ?? "FSS"}
/>
</Menu.Button>

View File

@ -30,9 +30,9 @@ const ProfileInformations: FunctionComponent = () => {
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
setValue("name", user.userProfile?.user_metadata.name ?? "");
setValue("email", user.userProfile?.email ?? "");
}, [setValue, user.userProfile]);
setValue("name", user.customer?.name ?? "");
setValue("email", user.customer?.email ?? "");
}, [setValue, user.customer]);
const onSubmit = handleSubmit(async ({ name, email }) => {
if (isSubmitting) {

View File

@ -32,8 +32,6 @@ export async function createCustomer({ id, email, name }: CreateCustomerParams):
if (error) throw error;
console.log("data", data);
return data![0];
}

View File

@ -15,6 +15,7 @@ export default function useAuth() {
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(async (event, session) => {
console.log("event", event);
if (["SIGNED_IN", "SIGNED_OUT"].includes(event)) {
await axios.post("/api/auth/session", { event, session });

View File

@ -1,34 +1,39 @@
import { useMutation, useQuery, useQueryClient } from "react-query";
import axios from "axios";
import { useAtom } from "jotai";
import type { Message } from "../database/message";
import useUser from "./use-user";
import { conversationsAtom, customerAtom, customerPhoneNumberAtom } from "../state";
import { useEffect } from "react";
type UseConversationParams = {
initialData?: Message[];
recipient: string;
}
export default function useConversation({
initialData,
recipient,
}: UseConversationParams) {
const user = useUser();
export default function useConversation(recipient: string) {
const customer = useAtom(customerAtom)[0];
const customerPhoneNumber = useAtom(customerPhoneNumberAtom)[0];
const getConversationUrl = `/api/conversation/${encodeURIComponent(recipient)}`;
const fetcher = async () => {
const { data } = await axios.get<Message[]>(getConversationUrl);
return data;
};
const queryClient = useQueryClient();
const getConversationQuery = useQuery<Message[]>(
const [conversations] = useAtom(conversationsAtom);
const getConversationQuery = useQuery<Message[] | null>(
getConversationUrl,
fetcher,
{
initialData,
initialData: null,
refetchInterval: false,
refetchOnWindowFocus: false,
},
);
useEffect(() => {
const conversation = conversations[recipient];
if (getConversationQuery.data?.length === 0) {
queryClient.setQueryData(getConversationUrl, conversation);
}
}, [queryClient, getConversationQuery.data, conversations, recipient, getConversationUrl]);
const sendMessage = useMutation(
(sms: Pick<Message, "to" | "content">) => axios.post(`/api/conversation/${sms.to}/send-message`, sms, { withCredentials: true }),
{
@ -41,8 +46,8 @@ export default function useConversation({
...previousMessages,
{
id: "", // TODO: somehow generate an id
from: "", // TODO: get user's phone number
customerId: user.userProfile!.id,
from: customerPhoneNumber!.phoneNumber,
customerId: customer!.id,
sentAt: new Date().toISOString(),
direction: "outbound",
status: "queued",
@ -54,12 +59,6 @@ export default function useConversation({
return { previousMessages };
},
onError: (error, variables, context) => {
if (context?.previousMessages) {
queryClient.setQueryData<Message[]>(getConversationUrl, context.previousMessages);
}
},
onSettled: () => queryClient.invalidateQueries(getConversationUrl),
},
);

View File

@ -1,11 +1,12 @@
import { useContext } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import type { User, UserAttributes } from "@supabase/supabase-js";
import { SessionContext } from "../session-context";
import appLogger from "../../lib/logger";
import supabase from "../supabase/client";
import { useAtom } from "jotai";
import { customerAtom } from "../state";
import { Customer } from "../database/customer";
const logger = appLogger.child({ module: "useUser" });
@ -16,28 +17,27 @@ type UseUser = {
| {
isLoading: true;
error: null;
userProfile: null;
customer: null;
}
| {
isLoading: false;
error: Error;
userProfile: User | null;
customer: Customer | null;
}
| {
isLoading: false;
error: null;
userProfile: User;
customer: Customer;
}
);
export default function useUser(): UseUser {
const session = useContext(SessionContext);
const [customer] = useAtom(customerAtom);
const router = useRouter();
return {
isLoading: session.state.user === null,
userProfile: session.state.user,
error: session.state.error,
isLoading: customer === null,
customer,
async deleteUser() {
await axios.post("/api/user/delete-user", null, {
withCredentials: true,

View File

@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "react-query";
import { Hydrate } from "react-query/hydration";
import { pageTitle } from "./_document";
import { SessionProvider } from "../session-context";
import "../fonts.css";
import "../tailwind.css";
@ -21,7 +20,6 @@ const NextApp = (props: AppProps) => {
return (
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>
<SessionProvider user={pageProps.user}>
<Head>
<meta
name="viewport"
@ -30,7 +28,6 @@ const NextApp = (props: AppProps) => {
<title>{pageTitle}</title>
</Head>
<Component {...pageProps} />
</SessionProvider>
</Hydrate>
</QueryClientProvider>
);

View File

@ -1,9 +1,9 @@
import Joi from "joi";
import { withApiAuthRequired } from "../../../../../lib/session-helpers";
import { findConversation } from "../../../../database/message";
import type { ApiError } from "../../_types";
import appLogger from "../../../../../lib/logger";
import { withApiAuthRequired } from "../../../../lib/session-helpers";
import { findConversation } from "../../../database/message";
import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger";
const logger = appLogger.child({ route: "/api/conversation" });

View File

@ -1,24 +1,19 @@
import type { InferGetServerSidePropsType, NextPage } from "next";
import type { NextPage } from "next";
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import { findCustomerPhoneCalls } from "../database/phone-call";
import useUser from "../hooks/use-user";
import Layout from "../components/layout";
import ConnectedLayout from "../components/connected-layout";
import { useAtom } from "jotai";
import { phoneCallsAtom } from "../state";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Props = {};
const pageTitle = "Calls";
const Calls: NextPage<Props> = ({ phoneCalls }) => {
const { userProfile } = useUser();
console.log("userProfile", userProfile);
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
const Calls: NextPage<Props> = () => {
const phoneCalls = useAtom(phoneCallsAtom)[0] ?? [];
return (
<ConnectedLayout>
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Calls page</p>
@ -35,22 +30,8 @@ const Calls: NextPage<Props> = ({ phoneCalls }) => {
</ul>
</div>
</Layout>
</ConnectedLayout>
);
};
export const getServerSideProps = withPageOnboardingRequired(
async ({ res }, user) => {
res.setHeader(
"Cache-Control",
"private, s-maxage=15, stale-while-revalidate=59",
);
const phoneCalls = await findCustomerPhoneCalls(user.id);
return {
props: { phoneCalls },
};
},
);
export default Calls;

View File

@ -7,21 +7,18 @@ import { faBackspace, faPhoneAlt as faPhone } from "@fortawesome/pro-solid-svg-i
import { withPageOnboardingRequired } from "../../lib/session-helpers";
import Layout from "../components/layout";
import useUser from "../hooks/use-user";
import ConnectedLayout from "../components/connected-layout";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Props = {};
const pageTitle = "Keypad";
const Keypad: NextPage<Props> = () => {
const { userProfile } = useUser();
const phoneNumber = useAtom(phoneNumberAtom)[0];
const pressBackspace = useAtom(pressBackspaceAtom)[1];
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
return (
<ConnectedLayout>
<Layout title={pageTitle}>
<div className="w-96 h-full flex flex-col justify-around py-5 mx-auto text-center text-black bg-white">
<div className="h-16 text-3xl text-gray-700">
@ -61,6 +58,7 @@ const Keypad: NextPage<Props> = () => {
</section>
</div>
</Layout>
</ConnectedLayout>
);
};
@ -118,13 +116,4 @@ const pressBackspaceAtom = atom(
},
);
export const getServerSideProps = withPageOnboardingRequired(({ res }) => {
res.setHeader(
"Cache-Control",
"private, s-maxage=15, stale-while-revalidate=59",
);
return { props: {} };
});
export default Keypad;

View File

@ -10,31 +10,33 @@ import {
import clsx from "clsx";
import { useForm } from "react-hook-form";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import type { Message } from "../../database/message";
import { findConversation } from "../../database/message";
import supabase from "../../supabase/client";
import useUser from "../../hooks/use-user";
import useConversation from "../../hooks/use-conversation";
import Layout from "../../components/layout";
import ConnectedLayout from "../../components/connected-layout";
type Props = {
recipient: string;
conversation: Message[];
}
type Props = {}
type Form = {
content: string;
}
const Messages: NextPage<Props> = (props) => {
const { userProfile } = useUser();
const { customer } = useUser();
const router = useRouter();
const recipient = router.query.recipient as string;
const { conversation, error, refetch, sendMessage } = useConversation({
initialData: props.conversation,
recipient,
});
useEffect(() => {
if (!router.isReady) {
return;
}
if (!recipient || Array.isArray(recipient)) {
router.push("/messages");
}
}, [recipient, router]);
const { conversation, error, refetch, sendMessage } = useConversation(recipient);
const formRef = useRef<HTMLFormElement>(null);
const {
register,
@ -58,12 +60,12 @@ const Messages: NextPage<Props> = (props) => {
});
useEffect(() => {
if (!userProfile) {
if (!customer) {
return;
}
const subscription = supabase
.from<Message>(`sms:customerId=eq.${userProfile.id}`)
.from<Message>(`sms:customerId=eq.${customer.id}`)
.on("INSERT", (payload) => {
const message = payload.new;
if ([message.from, message.to].includes(recipient)) {
@ -73,36 +75,39 @@ const Messages: NextPage<Props> = (props) => {
.subscribe();
return () => void subscription.unsubscribe();
}, [userProfile, recipient, refetch]);
}, [customer, recipient, refetch]);
useEffect(() => {
if (formRef.current) {
formRef.current.scrollIntoView();
}
}, []);
if (!userProfile) {
return (
<Layout title={pageTitle}>
Loading...
</Layout>
);
}
}, [conversation]);
if (error) {
console.error("error", error);
return (
<ConnectedLayout>
<Layout title={pageTitle}>
Oops, something unexpected happened. Please try reloading the page.
</Layout>
</ConnectedLayout>
);
}
console.log("conversation", conversation);
if (!conversation) {
return (
<ConnectedLayout>
<Layout title={pageTitle}>
Loading...
</Layout>
</ConnectedLayout>
);
}
return (
<ConnectedLayout>
<Layout title={pageTitle}>
<header className="grid grid-cols-3 items-center">
<header className="absolute top-0 w-screen h-12 backdrop-blur-sm bg-white bg-opacity-75 border-b grid grid-cols-3 items-center">
<span className="col-start-1 col-span-1 pl-2 cursor-pointer" onClick={router.back}>
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faLongArrowLeft} />
</span>
@ -114,9 +119,9 @@ const Messages: NextPage<Props> = (props) => {
<FontAwesomeIcon size="lg" className="h-8 w-8" icon={faInfoCircle} />
</span>
</header>
<div className="flex flex-col space-y-6 p-6">
<div className="flex flex-col space-y-6 p-6 pt-12">
<ul>
{conversation!.map((message, index) => {
{conversation.map((message, index) => {
const isOutbound = message.direction === "outbound";
const nextMessage = conversation![index + 1];
const previousMessage = conversation![index - 1];
@ -124,6 +129,7 @@ const Messages: NextPage<Props> = (props) => {
const isSamePrevious = message.from === previousMessage?.from;
const differenceInMinutes = previousMessage ? (new Date(message.sentAt).getTime() - new Date(previousMessage.sentAt).getTime()) / 1000 / 60 : 0;
const isTooLate = differenceInMinutes > 15;
console.log("message.from === previousMessage?.from", message.from, previousMessage?.from);
return (
<li key={message.id}>
{
@ -160,30 +166,8 @@ const Messages: NextPage<Props> = (props) => {
<button type="submit">Send</button>
</form>
</Layout>
</ConnectedLayout>
);
};
export const getServerSideProps = withPageOnboardingRequired<Props>(
async (context, user) => {
const recipient = context.params?.recipient;
if (!recipient || Array.isArray(recipient)) {
return {
redirect: {
destination: "/messages",
permanent: false,
},
};
}
const conversation = await findConversation(user.id, recipient);
return {
props: {
recipient,
conversation,
},
};
},
);
export default Messages;

View File

@ -8,85 +8,44 @@ import { findCustomer } from "../../database/customer";
import { decrypt } from "../../database/_encryption";
import useUser from "../../hooks/use-user";
import Layout from "../../components/layout";
import ConnectedLayout from "../../components/connected-layout";
import { conversationsAtom } from "../../state";
import { useAtom } from "jotai";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Props = {};
const pageTitle = "Messages";
const Messages: NextPage<Props> = ({ conversations }) => {
const { userProfile } = useUser();
if (!userProfile) {
return <Layout title={pageTitle}>Loading...</Layout>;
}
const Messages: NextPage<Props> = () => {
const [conversations] = useAtom(conversationsAtom);
return (
<ConnectedLayout>
<Layout title={pageTitle}>
<div className="flex flex-col space-y-6 p-6">
<p>Messages page</p>
<ul className="divide-y">
{Object.entries(conversations).map(([recipient, message]) => {
{Object.entries(conversations).map(([recipient, messages]) => {
const lastMessage = messages[messages.length - 1];
return (
<li key={recipient} className="py-2">
<Link href={`/messages/${encodeURIComponent(recipient)}`}>
<a className="flex flex-col">
<div className="flex flex-row justify-between">
<strong>{recipient}</strong>
<div>{new Date(message.sentAt).toLocaleString("fr-FR")}</div>
<div>{new Date(lastMessage.sentAt).toLocaleString("fr-FR")}</div>
</div>
<div>{message.content}</div>
<div>{lastMessage.content}</div>
</a>
</Link>
</li>
)
);
})}
</ul>
</div>
</Layout>
</ConnectedLayout>
);
};
type Recipient = string;
export const getServerSideProps = withPageOnboardingRequired(
async (context, user) => {
context.res.setHeader(
"Cache-Control",
"private, s-maxage=15, stale-while-revalidate=59",
);
const [customer, messages] = await Promise.all([
findCustomer(user.id),
findCustomerMessages(user.id),
]);
let conversations: Record<Recipient, Message> = {};
for (const message of messages) {
let recipient: string;
if (message.direction === "outbound") {
recipient = message.to;
} else {
recipient = message.from;
}
if (
!conversations[recipient] ||
message.sentAt > conversations[recipient].sentAt
) {
conversations[recipient] = {
...message,
content: decrypt(message.content, customer.encryptionKey), // TODO: should probably decrypt on the phone
};
}
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(([,a], [,b]) => b.sentAt.localeCompare(a.sentAt))
);
return {
props: { conversations },
};
},
);
export default Messages;

View File

@ -9,14 +9,11 @@ import Divider from "../../components/divider";
import UpdatePassword from "../../components/settings/update-password";
import DangerZone from "../../components/settings/danger-zone";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import ConnectedLayout from "../../components/connected-layout";
const Account: NextPage = () => {
const user = useUser();
if (user.isLoading) {
return <SettingsLayout>Loading...</SettingsLayout>;
}
if (user.error !== null) {
return (
<SettingsLayout>
@ -32,6 +29,7 @@ const Account: NextPage = () => {
}
return (
<ConnectedLayout>
<SettingsLayout>
<div className="flex flex-col space-y-6 p-6">
<ProfileInformations />
@ -49,6 +47,7 @@ const Account: NextPage = () => {
<DangerZone />
</div>
</SettingsLayout>
</ConnectedLayout>
);
};

View File

@ -14,6 +14,7 @@ import type { Subscription } from "../../database/subscriptions";
import { findUserSubscription } from "../../database/subscriptions";
import appLogger from "../../../lib/logger";
import ConnectedLayout from "../../components/connected-layout";
const logger = appLogger.child({ page: "/account/settings/billing" });
@ -31,6 +32,7 @@ const Billing: NextPage<Props> = ({ subscription }) => {
const { cancelSubscription, updatePaymentMethod } = useSubscription();
return (
<ConnectedLayout>
<SettingsLayout>
<div className="flex flex-col space-y-6 p-6">
{subscription ? (
@ -76,6 +78,7 @@ const Billing: NextPage<Props> = ({ subscription }) => {
)}
</div>
</SettingsLayout>
</ConnectedLayout>
);
};

View File

@ -6,8 +6,9 @@ import Layout from "../../components/layout";
import { withPageOnboardingRequired } from "../../../lib/session-helpers";
import appLogger from "../../../lib/logger";
import ConnectedLayout from "../../components/connected-layout";
type Props = InferGetServerSidePropsType<typeof getServerSideProps>;
type Props = {};
const logger = appLogger.child({ page: "/account/settings" });
@ -28,6 +29,7 @@ const navigation = [
const Settings: NextPage<Props> = (props) => {
return (
<ConnectedLayout>
<Layout title="Settings">
<div className="flex flex-col space-y-6 p-6">
<aside className="py-6 lg:col-span-3">
@ -46,18 +48,8 @@ const Settings: NextPage<Props> = (props) => {
</aside>
</div>
</Layout>
</ConnectedLayout>
);
};
export const getServerSideProps = withPageOnboardingRequired(
async ({ res }) => {
res.setHeader(
"Cache-Control",
"private, s-maxage=15, stale-while-revalidate=59",
);
return { props: {} };
},
);
export default Settings;

View File

@ -1,105 +0,0 @@
import type { Dispatch, ReactNode, Reducer, ReducerAction } from "react";
import { createContext, useEffect, useReducer } from "react";
import type { User } from "@supabase/supabase-js";
import supabase from "./supabase/client";
type Context = {
state: SessionState;
dispatch: Dispatch<ReducerAction<typeof sessionReducer>>;
};
export const SessionContext = createContext<Context>(null as any);
type ProviderProps = {
children: ReactNode;
user?: User | null;
};
function getInitialState(initialUser: User | null | undefined): SessionState {
if (!initialUser) {
return {
state: "LOADING",
user: null,
error: null,
};
}
return {
state: "SUCCESS",
user: initialUser,
error: null,
};
}
export function SessionProvider({ children, user }: ProviderProps) {
const [state, dispatch] = useReducer(
sessionReducer,
getInitialState(user),
);
useEffect(() => {
supabase.auth.onAuthStateChange((event, session) => {
console.log("event", event);
if (["SIGNED_IN", "USER_UPDATED"].includes(event)) {
dispatch({
type: "SET_SESSION",
user: session!.user!,
});
}
});
if (state.user === null) {
dispatch({
type: "SET_SESSION",
user: supabase.auth.user()!,
});
}
}, []);
return (
<SessionContext.Provider value={{ state, dispatch }}>
{children}
</SessionContext.Provider>
);
}
type SessionState =
| {
state: "LOADING";
user: null;
error: null;
}
| {
state: "SUCCESS";
user: User;
error: null;
}
| {
state: "ERROR";
user: User | null;
error: Error;
};
type Action =
| { type: "SET_SESSION"; user: User }
| { type: "THROW_ERROR"; error: Error };
const sessionReducer: Reducer<SessionState, Action> = (state, action) => {
switch (action.type) {
case "SET_SESSION":
return {
...state,
state: "SUCCESS",
user: action.user,
error: null,
};
case "THROW_ERROR":
return {
...state,
state: "ERROR",
error: action.error,
};
default:
throw new Error("unreachable");
}
};

50
src/state.ts Normal file
View File

@ -0,0 +1,50 @@
import { atom } from "jotai";
import type { Message } from "./database/message";
import type { Customer } from "./database/customer";
import type { PhoneCall } from "./database/phone-call";
import type { PhoneNumber } from "./database/phone-number";
import { decrypt } from "./database/_encryption";
type Recipient = string;
export const customerAtom = atom<Customer | null>(null);
export const customerPhoneNumberAtom = atom<PhoneNumber | null>(null);
export const messagesAtom = atom<Message[] | null>(null);
export const conversationsAtom = atom<Record<Recipient, Message[]>>(
(get) => {
const messages = get(messagesAtom);
const customer = get(customerAtom);
if (!customer || !messages) {
return {};
}
let conversations: Record<Recipient, Message[]> = {};
for (const message of messages) {
let recipient: string;
if (message.direction === "outbound") {
recipient = message.to;
} else {
recipient = message.from;
}
if (!conversations[recipient]) {
conversations[recipient] = [];
}
conversations[recipient].push({
...message,
content: decrypt(message.content, customer.encryptionKey),
});
conversations[recipient].sort((a, b) => a.sentAt.localeCompare(b.sentAt));
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(([,a], [,b]) => b[b.length - 1].sentAt.localeCompare(a[a.length - 1].sentAt))
);
return conversations;
},
);
export const phoneCallsAtom = atom<PhoneCall[] | null>(null);