Skip to content

Commit 992187f

Browse files
feat: convo reply widgets (#449)
1 parent 6cc1b26 commit 992187f

File tree

13 files changed

+332
-31
lines changed

13 files changed

+332
-31
lines changed

apps/mail-bridge/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import { trpcServer } from '@hono/trpc-server';
77
import { eventApi } from './postal-routes/events';
88
import { inboundApi } from './postal-routes/inbound';
99
import { signatureMiddleware } from './postal-routes/signature-middleware';
10+
import { logger } from 'hono/logger';
1011

1112
const app = new Hono();
1213

14+
// Logger middleware
15+
if (env.NODE_ENV === 'development') {
16+
app.use(logger());
17+
}
18+
1319
// Health check endpoint
1420
app.get('/', (c) => c.json({ status: "I'm Alive 🏝️" }));
1521

apps/platform/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@ import { trpcPlatformRouter } from './trpc';
99
import { db } from '@u22n/database';
1010
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
1111
import { authMiddleware } from './middlewares';
12+
import { logger } from 'hono/logger';
1213

1314
const app = new Hono<Ctx>();
1415

16+
// Logger middleware
17+
if (env.NODE_ENV === 'development') {
18+
app.use(logger());
19+
}
20+
1521
// CORS middleware
1622
app.use(
1723
'*',

apps/platform/trpc/routers/convoRouter/convoRouter.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -868,18 +868,26 @@ export const convoRouter = router({
868868
}
869869

870870
let authorConvoParticipantId: number | undefined;
871+
let authorConvoParticipantPublicId:
872+
| TypeId<'convoParticipants'>
873+
| undefined;
871874
authorConvoParticipantId =
872875
convoEntryToReplyToQueryResponse?.convo.participants.find(
873876
(participant) => participant.orgMemberId === accountOrgMemberId
874877
)?.id;
878+
authorConvoParticipantPublicId =
879+
convoEntryToReplyToQueryResponse?.convo.participants.find(
880+
(participant) => participant.orgMemberId === accountOrgMemberId
881+
)?.publicId;
875882
// if we cant find the orgMembers participant id, we assume they're a part of the convo as a team member and we're somehow skipped accidentally, so now we add them as a dedicated participant
876883
if (!authorConvoParticipantId) {
884+
authorConvoParticipantPublicId = typeIdGenerator('convoParticipants');
877885
const newConvoParticipantInsertResponse = await db
878886
.insert(convoParticipants)
879887
.values({
880888
convoId: convoEntryToReplyToQueryResponse.convoId,
881889
orgId: orgId,
882-
publicId: typeIdGenerator('convoParticipants'),
890+
publicId: authorConvoParticipantPublicId,
883891
orgMemberId: accountOrgMemberId,
884892
emailIdentityId: emailIdentityId,
885893
role: 'contributor'
@@ -1275,17 +1283,17 @@ export const convoRouter = router({
12751283
}),
12761284

12771285
//* get convo entries
1278-
getConvoEntries: orgProcedure
1279-
.input(
1280-
z.object({
1281-
convoPublicId: typeIdValidator('convos'),
1282-
cursor: z.object({
1283-
lastUpdatedAt: z.date().optional(),
1284-
lastPublicId: typeIdValidator('convos').optional()
1285-
})
1286-
})
1287-
)
1288-
.query(async () => {}),
1286+
// getConvoEntries: orgProcedure
1287+
// .input(
1288+
// z.object({
1289+
// convoPublicId: typeIdValidator('convos'),
1290+
// cursor: z.object({
1291+
// lastUpdatedAt: z.date().optional(),
1292+
// lastPublicId: typeIdValidator('convos').optional()
1293+
// })
1294+
// })
1295+
// )
1296+
// .query(async () => {}),
12891297

12901298
getOrgMemberConvos: orgProcedure
12911299
.input(

apps/storage/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ import { mailfetchApi } from './api/mailfetch';
1111
import { internalPresignApi } from './api/internalPresign';
1212
import { deleteAttachmentsApi } from './api/deleteAttachments';
1313
import type { Ctx } from './ctx';
14+
import { logger } from 'hono/logger';
1415

1516
const app = new Hono<Ctx>();
1617

18+
// Logger middleware
19+
if (env.NODE_ENV === 'development') {
20+
app.use(logger());
21+
}
22+
1723
// CORS middleware
1824
app.use(
1925
'*',

apps/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
"dev": "PORT=3000 next dev --turbo",
88
"build": "next build",
99
"start": "next start",
10-
"check": "next lint"
10+
"check": "tsc --noEmit && next lint"
1111
},
1212
"dependencies": {
1313
"@phosphor-icons/react": "^2.1.5",
1414
"@radix-ui/react-slot": "^1.0.2",
1515
"@radix-ui/themes": "^3.0.2",
1616
"@simplewebauthn/browser": "^10.0.0",
17+
"@tailwindcss/typography": "^0.5.13",
1718
"@tanstack/react-query": "^4.36.1",
1819
"@tanstack/react-table": "^8.16.0",
1920
"@tanstack/react-virtual": "^3.5.0",

apps/web/src/app/[orgShortCode]/convo/[convoId]/_components/chat-sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export default function ChatSideBar({
126126
style={{ zIndex: 100 + i }}
127127
className={cn(
128128
!participantOpen && i !== 0 ? '-ml-2' : '',
129-
'dark:outline-graydark-1 dark:bg-graydark-1 w-fit rounded-full outline'
129+
'dark:outline-graydark-1 dark:bg-graydark-1 outline-gray-1 bg-gray-1 w-fit rounded-full outline'
130130
)}>
131131
<Avatar
132132
src={

apps/web/src/app/[orgShortCode]/convo/[convoId]/page.tsx

Lines changed: 172 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,38 @@ import {
1111
Text,
1212
Badge,
1313
Skeleton,
14-
Spinner
14+
Spinner,
15+
Select
1516
} from '@radix-ui/themes';
1617
import Link from 'next/link';
1718
import { type TypeId, validateTypeId } from '@u22n/utils';
1819
import { useGlobalStore } from '@/src/providers/global-store-provider';
19-
import { memo, useCallback, useMemo, useState } from 'react';
20+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2021
import { type JSONContent, generateHTML } from '@u22n/tiptap/react';
2122
import { tipTapExtensions } from '@u22n/tiptap/extensions';
22-
import { formatParticipantData } from '../utils';
23+
import {
24+
formatParticipantData,
25+
useUpdateConvoMessageList$Cache
26+
} from '../utils';
2327
import { cn, generateAvatarUrl, getInitials } from '@/src/lib/utils';
2428
import ChatSideBar from './_components/chat-sidebar';
2529
import useTimeAgo from '@/src/hooks/use-time-ago';
2630
import { ArrowLeft, Ellipsis } from 'lucide-react';
27-
import { atom, useAtom } from 'jotai';
31+
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
2832
import { useCopyToClipboard } from '@uidotdev/usehooks';
2933
import { toast } from 'sonner';
3034
import { ms } from 'itty-time';
3135
import { Virtuoso } from 'react-virtuoso';
36+
import Editor from '../new/editor';
37+
import { emptyTiptapEditorContent } from '@u22n/tiptap';
38+
import AttachmentButton, {
39+
type ConvoAttachmentUpload
40+
} from '../new/attachment-button';
41+
import { stringify } from 'superjson';
42+
import { type Editor as EditorType } from '@u22n/tiptap/react';
3243

3344
const replyToMessageAtom = atom<null | TypeId<'convoEntries'>>(null);
45+
const selectedEmailIdentityAtom = atom<null | TypeId<'emailIdentities'>>(null);
3446

3547
export default function Page({
3648
params: { convoId }
@@ -53,6 +65,8 @@ function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) {
5365
INVERSE_LIST_START_INDEX
5466
);
5567
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null);
68+
const setReplyTo = useSetAtom(replyToMessageAtom);
69+
const [emailIdentity, setEmailIdentity] = useAtom(selectedEmailIdentityAtom);
5670

5771
const {
5872
data: convoData,
@@ -63,6 +77,11 @@ function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) {
6377
convoPublicId: convoId
6478
});
6579

80+
const { data: emailIdentities, isLoading: emailIdentitiesLoading } =
81+
api.org.mail.emailIdentities.getUserEmailIdentities.useQuery({
82+
orgShortCode
83+
});
84+
6685
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
6786
api.convos.entries.getConvoEntries.useInfiniteQuery(
6887
{
@@ -83,6 +102,18 @@ function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) {
83102
return messages;
84103
}, [data]);
85104

105+
useEffect(() => {
106+
const lastMessage = allMessages.at(-1);
107+
setReplyTo(() => lastMessage?.publicId ?? null);
108+
}, [allMessages, setReplyTo]);
109+
110+
useEffect(() => {
111+
setEmailIdentity((prev) => {
112+
if (!emailIdentities) return prev;
113+
return prev ?? emailIdentities.emailIdentities[0]?.publicId ?? null;
114+
});
115+
}, [emailIdentities, setEmailIdentity]);
116+
86117
const participantOwnPublicId = convoData?.ownParticipantPublicId;
87118
const convoHidden = useMemo(
88119
() =>
@@ -177,7 +208,36 @@ function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) {
177208
/>
178209
</ScrollArea>
179210
)}
180-
<Flex className="h-20">Editor box</Flex>
211+
<Flex
212+
className="border-top min-h-32"
213+
direction="column"
214+
justify="end"
215+
gap="1">
216+
{!emailIdentitiesLoading ? (
217+
<div className="flex items-center gap-1">
218+
<span className="text-gray-9 px-2 text-sm">Reply as</span>
219+
<Select.Root
220+
value={emailIdentity ?? undefined}
221+
onValueChange={(email) =>
222+
setEmailIdentity(email as TypeId<'emailIdentities'>)
223+
}>
224+
<Select.Trigger className="text-xs" />
225+
<Select.Content>
226+
{emailIdentities?.emailIdentities.map((email) => (
227+
<Select.Item
228+
key={email.publicId}
229+
value={email.publicId}>
230+
<span>
231+
{email.username}@{email.domainName}
232+
</span>
233+
</Select.Item>
234+
))}
235+
</Select.Content>
236+
</Select.Root>
237+
</div>
238+
) : null}
239+
<MessageReplyBox convoId={convoId} />
240+
</Flex>
181241
</Flex>
182242
<ChatSideBar
183243
participants={allParticipants}
@@ -188,6 +248,109 @@ function ConvoView({ convoId }: { convoId: TypeId<'convos'> }) {
188248
);
189249
}
190250

251+
const attachmentsAtom = atom<ConvoAttachmentUpload[]>([]);
252+
253+
function MessageReplyBox({ convoId }: { convoId: TypeId<'convos'> }) {
254+
const [editorText, setEditorText] = useState<JSONContent>(
255+
emptyTiptapEditorContent
256+
);
257+
const [attachments, setAttachments] = useAtom(attachmentsAtom);
258+
const orgShortCode = useGlobalStore((state) => state.currentOrg.shortCode);
259+
const replyTo = useAtomValue(replyToMessageAtom);
260+
const addConvoToCache = useUpdateConvoMessageList$Cache();
261+
const editorRef = useRef<EditorType | null>(null);
262+
const emailIdentity = useAtomValue(selectedEmailIdentityAtom);
263+
264+
// TODO: Find a better way to handle this
265+
const [loadingType, setLoadingType] = useState<'comment' | 'message'>(
266+
'message'
267+
);
268+
269+
const replyToConvoMutation = api.convos.replyToConvo.useMutation({
270+
onSuccess: () => {
271+
editorRef.current?.commands.clearContent();
272+
setEditorText(emptyTiptapEditorContent);
273+
setAttachments([]);
274+
},
275+
onError: (err) => {
276+
toast.error(err.message);
277+
}
278+
});
279+
280+
const isEditorEmpty = useMemo(() => {
281+
const contentArray = editorText?.content;
282+
if (!contentArray) return true;
283+
if (contentArray.length === 0) return true;
284+
if (
285+
contentArray[0] &&
286+
(!contentArray[0].content || contentArray[0].content.length === 0)
287+
)
288+
return true;
289+
return false;
290+
}, [editorText]);
291+
292+
return (
293+
<div className="flex max-h-[250px] w-full flex-col gap-1 p-1">
294+
<Editor
295+
initialValue={editorText}
296+
onChange={setEditorText}
297+
setEditor={(editor) => {
298+
editorRef.current = editor;
299+
}}
300+
/>
301+
<div className="align-center flex justify-end gap-2">
302+
<AttachmentButton attachmentsAtom={attachmentsAtom} />
303+
<Button
304+
variant="soft"
305+
loading={replyToConvoMutation.isLoading && loadingType === 'comment'}
306+
disabled={
307+
!replyTo ||
308+
isEditorEmpty ||
309+
!emailIdentity ||
310+
replyToConvoMutation.isLoading
311+
}
312+
onClick={async () => {
313+
if (!replyTo || !emailIdentity) return;
314+
setLoadingType('comment');
315+
const { publicId } = await replyToConvoMutation.mutateAsync({
316+
attachments,
317+
orgShortCode,
318+
message: stringify(editorText),
319+
replyToMessagePublicId: replyTo,
320+
messageType: 'comment'
321+
});
322+
await addConvoToCache(convoId, publicId);
323+
}}>
324+
Comment
325+
</Button>
326+
<Button
327+
loading={replyToConvoMutation.isLoading && loadingType === 'message'}
328+
disabled={
329+
!replyTo ||
330+
isEditorEmpty ||
331+
!emailIdentity ||
332+
replyToConvoMutation.isLoading
333+
}
334+
onClick={async () => {
335+
if (!replyTo || !emailIdentity) return;
336+
setLoadingType('message');
337+
const { publicId } = await replyToConvoMutation.mutateAsync({
338+
attachments,
339+
orgShortCode,
340+
message: stringify(editorText),
341+
replyToMessagePublicId: replyTo,
342+
messageType: 'message',
343+
sendAsEmailIdentityPublicId: emailIdentity
344+
});
345+
await addConvoToCache(convoId, publicId);
346+
}}>
347+
Send
348+
</Button>
349+
</div>
350+
</div>
351+
);
352+
}
353+
191354
function MessageItem({
192355
message,
193356
participantOwnPublicId,
@@ -246,7 +409,9 @@ function MessageItem({
246409
<Flex
247410
className={cn(
248411
'w-full max-w-full overflow-hidden rounded-lg p-2',
249-
isUserAuthor ? 'bg-blue-10' : 'bg-gray-11'
412+
isUserAuthor
413+
? 'dark:bg-blue-10 bg-blue-8'
414+
: 'dark:bg-gray-10 bg-gray-8'
250415
)}>
251416
<HTMLMessage html={messageHtml} />
252417
</Flex>
@@ -304,7 +469,7 @@ const HTMLMessage = memo(
304469
return (
305470
<div
306471
dangerouslySetInnerHTML={{ __html }}
307-
className="w-full max-w-full overflow-clip break-words"
472+
className="prose dark:prose-invert prose-p:my-1 prose-a:decoration-blue-8 prose-img:my-1 w-full max-w-full overflow-clip break-words text-black dark:text-white"
308473
/>
309474
);
310475
},

0 commit comments

Comments
 (0)