|
@@ -1,8 +1,10 @@
|
|
|
'use client'
|
|
'use client'
|
|
|
|
|
|
|
|
-import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
|
|
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
import { useRouter } from 'next/navigation'
|
|
import { useRouter } from 'next/navigation'
|
|
|
|
|
+import Link from 'next/link'
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useTranslations } from 'next-intl'
|
|
|
|
|
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
|
|
|
import { cn } from '@/lib/utils'
|
|
import { cn } from '@/lib/utils'
|
|
|
import {
|
|
import {
|
|
|
ArrowLeft,
|
|
ArrowLeft,
|
|
@@ -60,10 +62,14 @@ import {
|
|
|
getInboxThreads,
|
|
getInboxThreads,
|
|
|
markThreadRead,
|
|
markThreadRead,
|
|
|
markThreadUnread,
|
|
markThreadUnread,
|
|
|
|
|
+ type EmailChannelState,
|
|
|
type InboxThread,
|
|
type InboxThread,
|
|
|
type MessagingChannel,
|
|
type MessagingChannel,
|
|
|
} from '../Actions/inboxActions'
|
|
} from '../Actions/inboxActions'
|
|
|
|
|
+import { deleteEmailConversation } from '../Actions/emailThreadActions'
|
|
|
import { ChannelBadge, channelLabel } from './ChannelBadge'
|
|
import { ChannelBadge, channelLabel } from './ChannelBadge'
|
|
|
|
|
+import { EmailConversation } from './EmailConversation'
|
|
|
|
|
+import { NewEmailDialog } from './NewEmailDialog'
|
|
|
import { avatarTint, initials } from '../Lib/threadDisplay'
|
|
import { avatarTint, initials } from '../Lib/threadDisplay'
|
|
|
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
|
|
import { useDebouncedSearch } from '@/hooks/use-debounced-search'
|
|
|
|
|
|
|
@@ -90,7 +96,7 @@ export interface InboxFocus {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Channels a workshop can start a conversation on. */
|
|
/** Channels a workshop can start a conversation on. */
|
|
|
-const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp']
|
|
|
|
|
|
|
+const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp', 'email']
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Taken from what the actions actually return rather than declared here.
|
|
* Taken from what the actions actually return rather than declared here.
|
|
@@ -107,6 +113,7 @@ export function UnifiedInbox({
|
|
|
threads: initialThreads,
|
|
threads: initialThreads,
|
|
|
initialCursor = null,
|
|
initialCursor = null,
|
|
|
channels,
|
|
channels,
|
|
|
|
|
+ emailState,
|
|
|
onChanged,
|
|
onChanged,
|
|
|
focus = null,
|
|
focus = null,
|
|
|
}: {
|
|
}: {
|
|
@@ -114,12 +121,18 @@ export function UnifiedInbox({
|
|
|
/** Where the next page starts, or null when the first page is all of it. */
|
|
/** Where the next page starts, or null when the first page is all of it. */
|
|
|
initialCursor?: string | null
|
|
initialCursor?: string | null
|
|
|
channels: MessagingChannel[]
|
|
channels: MessagingChannel[]
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Where email replies land. With the workshop's own vendor they go to its
|
|
|
|
|
+ * own mailbox, so email shows as a channel that is answered elsewhere.
|
|
|
|
|
+ */
|
|
|
|
|
+ emailState: EmailChannelState
|
|
|
/** Asks the page to reload its server data after something is sent. */
|
|
/** Asks the page to reload its server data after something is sent. */
|
|
|
onChanged?: () => void
|
|
onChanged?: () => void
|
|
|
/** The conversation the address asks for, opened on arrival. */
|
|
/** The conversation the address asks for, opened on arrival. */
|
|
|
focus?: InboxFocus | null
|
|
focus?: InboxFocus | null
|
|
|
}) {
|
|
}) {
|
|
|
const t = useTranslations('messaging.inbox')
|
|
const t = useTranslations('messaging.inbox')
|
|
|
|
|
+ const te = useTranslations('messaging.email')
|
|
|
const router = useRouter()
|
|
const router = useRouter()
|
|
|
const [threads, setThreads] = useState(initialThreads)
|
|
const [threads, setThreads] = useState(initialThreads)
|
|
|
const [cursor, setCursor] = useState<string | null>(initialCursor)
|
|
const [cursor, setCursor] = useState<string | null>(initialCursor)
|
|
@@ -208,6 +221,46 @@ export function UnifiedInbox({
|
|
|
[router]
|
|
[router]
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * The open thread as the list now has it. The list is replaced whenever
|
|
|
|
|
+ * the page's data reloads, and a reply that arrived meanwhile shows there
|
|
|
|
|
+ * first: a newer `lastAt` on the open row means the conversation has
|
|
|
|
|
+ * something to re-read, and that it has been read by being on screen.
|
|
|
|
|
+ */
|
|
|
|
|
+ const current = useMemo(
|
|
|
|
|
+ () => (selected ? (threads.find((row) => row.key === selected.key) ?? selected) : null),
|
|
|
|
|
+ [threads, selected]
|
|
|
|
|
+ )
|
|
|
|
|
+ const seen = useRef<{ key: string; lastAt: string } | null>(null)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!current) {
|
|
|
|
|
+ seen.current = null
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (seen.current?.key !== current.key) {
|
|
|
|
|
+ // Opening it marked it read already.
|
|
|
|
|
+ seen.current = { key: current.key, lastAt: current.lastAt }
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (seen.current.lastAt === current.lastAt) return
|
|
|
|
|
+ seen.current = { key: current.key, lastAt: current.lastAt }
|
|
|
|
|
+ if (current.unread > 0) void markRead(current)
|
|
|
|
|
+ }, [current, markRead])
|
|
|
|
|
+
|
|
|
|
|
+ // A message arriving on any channel is announced to the workshop's
|
|
|
|
|
+ // notification feed over the socket. That is the inbox's cue to reload
|
|
|
|
|
+ // its data; the feed's own first fill, of older notifications, is not.
|
|
|
|
|
+ const latest = useNotificationStore((state) => state.notifications[0])
|
|
|
|
|
+ const mountedAt = useRef(Date.now())
|
|
|
|
|
+ const announced = useRef<string | null>(null)
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!latest || announced.current === latest.id) return
|
|
|
|
|
+ announced.current = latest.id
|
|
|
|
|
+ if (!latest.type.endsWith('_inbound')) return
|
|
|
|
|
+ if (new Date(latest.createdAt).getTime() < mountedAt.current - 60_000) return
|
|
|
|
|
+ onChanged?.()
|
|
|
|
|
+ }, [latest, onChanged])
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* The opposite gesture, for "come back to this later". The thread stays
|
|
* The opposite gesture, for "come back to this later". The thread stays
|
|
|
* open; it is only the marker and the pill that come back. Selecting it
|
|
* open; it is only the marker and the pill that come back. Selecting it
|
|
@@ -236,7 +289,10 @@ export function UnifiedInbox({
|
|
|
[router, t]
|
|
[router, t]
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- /** SMS and Telegram hand their history over as props, so it loads on select. */
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SMS and Telegram hand their history over as props, so it loads on select.
|
|
|
|
|
+ * WhatsApp and email load their own.
|
|
|
|
|
+ */
|
|
|
const select = useCallback(
|
|
const select = useCallback(
|
|
|
async (thread: InboxThread) => {
|
|
async (thread: InboxThread) => {
|
|
|
setSelected(thread)
|
|
setSelected(thread)
|
|
@@ -294,14 +350,18 @@ export function UnifiedInbox({
|
|
|
setIsDeleting(true)
|
|
setIsDeleting(true)
|
|
|
|
|
|
|
|
// A WhatsApp thread can exist under a bare number, and those are the ones
|
|
// A WhatsApp thread can exist under a bare number, and those are the ones
|
|
|
- // most worth clearing away: test sends and wrong numbers.
|
|
|
|
|
- const result = !thread.customerId
|
|
|
|
|
- ? await deleteWhatsappConversationByPhone(thread.contact)
|
|
|
|
|
- : thread.channel === 'sms'
|
|
|
|
|
- ? await deleteConversation(thread.customerId)
|
|
|
|
|
- : thread.channel === 'telegram'
|
|
|
|
|
- ? await deleteTelegramConversation(thread.customerId)
|
|
|
|
|
- : await deleteWhatsappConversation(thread.customerId)
|
|
|
|
|
|
|
+ // most worth clearing away: test sends and wrong numbers. Email threads
|
|
|
|
|
+ // are filed by customer or by address, and one action handles both.
|
|
|
|
|
+ const result =
|
|
|
|
|
+ thread.channel === 'email'
|
|
|
|
|
+ ? await deleteEmailConversation({ customerId: thread.customerId, contact: thread.contact })
|
|
|
|
|
+ : !thread.customerId
|
|
|
|
|
+ ? await deleteWhatsappConversationByPhone(thread.contact)
|
|
|
|
|
+ : thread.channel === 'sms'
|
|
|
|
|
+ ? await deleteConversation(thread.customerId)
|
|
|
|
|
+ : thread.channel === 'telegram'
|
|
|
|
|
+ ? await deleteTelegramConversation(thread.customerId)
|
|
|
|
|
+ : await deleteWhatsappConversation(thread.customerId)
|
|
|
|
|
|
|
|
setIsDeleting(false)
|
|
setIsDeleting(false)
|
|
|
setDeleteTarget(null)
|
|
setDeleteTarget(null)
|
|
@@ -316,7 +376,9 @@ export function UnifiedInbox({
|
|
|
|
|
|
|
|
const startCompose = () => {
|
|
const startCompose = () => {
|
|
|
const options = channels.filter((channel) => INITIABLE.includes(channel))
|
|
const options = channels.filter((channel) => INITIABLE.includes(channel))
|
|
|
- if (options.length === 1) {
|
|
|
|
|
|
|
+ // The dialog is where a workshop on its own email vendor learns why email
|
|
|
|
|
+ // is not offered, so it is always shown to them, even with one choice.
|
|
|
|
|
+ if (options.length === 1 && emailState !== 'ownVendor') {
|
|
|
setComposeChannel(options[0])
|
|
setComposeChannel(options[0])
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -325,7 +387,7 @@ export function UnifiedInbox({
|
|
|
|
|
|
|
|
const openThreadFor = (
|
|
const openThreadFor = (
|
|
|
channel: MessagingChannel,
|
|
channel: MessagingChannel,
|
|
|
- customer: { id: string; name: string; phone?: string | null }
|
|
|
|
|
|
|
+ customer: { id: string; name: string; phone?: string | null; email?: string | null }
|
|
|
) => {
|
|
) => {
|
|
|
const existing = threads.find(
|
|
const existing = threads.find(
|
|
|
(thread) => thread.channel === channel && thread.customerId === customer.id
|
|
(thread) => thread.channel === channel && thread.customerId === customer.id
|
|
@@ -336,7 +398,7 @@ export function UnifiedInbox({
|
|
|
channel,
|
|
channel,
|
|
|
customerId: customer.id,
|
|
customerId: customer.id,
|
|
|
name: customer.name,
|
|
name: customer.name,
|
|
|
- contact: customer.phone ?? '',
|
|
|
|
|
|
|
+ contact: (channel === 'email' ? customer.email : customer.phone) ?? '',
|
|
|
lastMessage: '',
|
|
lastMessage: '',
|
|
|
lastDirection: 'outbound',
|
|
lastDirection: 'outbound',
|
|
|
lastAt: new Date(0).toISOString(),
|
|
lastAt: new Date(0).toISOString(),
|
|
@@ -346,6 +408,7 @@ export function UnifiedInbox({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
|
|
const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
|
|
|
|
|
+ const canCompose = composeOptions.length > 0 || emailState === 'ownVendor'
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
@@ -364,7 +427,7 @@ export function UnifiedInbox({
|
|
|
</div>
|
|
</div>
|
|
|
{/* Starting a conversation used to be offered only by the empty
|
|
{/* Starting a conversation used to be offered only by the empty
|
|
|
state, so it disappeared as soon as there was anything to read. */}
|
|
state, so it disappeared as soon as there was anything to read. */}
|
|
|
- {composeOptions.length > 0 && (
|
|
|
|
|
|
|
+ {canCompose && (
|
|
|
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
|
|
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
{t('newMessage')}
|
|
{t('newMessage')}
|
|
@@ -389,7 +452,7 @@ export function UnifiedInbox({
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
|
{search ? t('noSearchResults') : t('empty')}
|
|
{search ? t('noSearchResults') : t('empty')}
|
|
|
</p>
|
|
</p>
|
|
|
- {!search && composeOptions.length > 0 && (
|
|
|
|
|
|
|
+ {!search && canCompose && (
|
|
|
<Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
|
|
<Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
{t('newMessage')}
|
|
{t('newMessage')}
|
|
@@ -499,7 +562,9 @@ export function UnifiedInbox({
|
|
|
</div>
|
|
</div>
|
|
|
<p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
|
|
<p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
|
|
|
</div>
|
|
</div>
|
|
|
- {(selected.customerId || selected.channel === 'whatsapp') && (
|
|
|
|
|
|
|
+ {(selected.customerId ||
|
|
|
|
|
+ selected.channel === 'whatsapp' ||
|
|
|
|
|
+ selected.channel === 'email') && (
|
|
|
<DropdownMenu>
|
|
<DropdownMenu>
|
|
|
<DropdownMenuTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
|
@@ -540,6 +605,20 @@ export function UnifiedInbox({
|
|
|
onSent={onChanged}
|
|
onSent={onChanged}
|
|
|
className="h-auto min-h-0 flex-1"
|
|
className="h-auto min-h-0 flex-1"
|
|
|
/>
|
|
/>
|
|
|
|
|
+ ) : selected.channel === 'email' ? (
|
|
|
|
|
+ <EmailConversation
|
|
|
|
|
+ key={selected.key}
|
|
|
|
|
+ thread={{
|
|
|
|
|
+ key: selected.key,
|
|
|
|
|
+ customerId: selected.customerId,
|
|
|
|
|
+ name: selected.name,
|
|
|
|
|
+ address: selected.contact,
|
|
|
|
|
+ }}
|
|
|
|
|
+ emailState={emailState}
|
|
|
|
|
+ onSent={onChanged}
|
|
|
|
|
+ refreshKey={current?.lastAt ?? selected.lastAt}
|
|
|
|
|
+ className="h-auto min-h-0 flex-1"
|
|
|
|
|
+ />
|
|
|
) : selected.channel === 'sms' && smsData && selected.customerId ? (
|
|
) : selected.channel === 'sms' && smsData && selected.customerId ? (
|
|
|
<SmsConversation
|
|
<SmsConversation
|
|
|
key={selected.key}
|
|
key={selected.key}
|
|
@@ -570,7 +649,7 @@ export function UnifiedInbox({
|
|
|
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
|
|
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
|
|
|
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
|
|
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
|
|
|
<p className="text-sm">{t('selectConversation')}</p>
|
|
<p className="text-sm">{t('selectConversation')}</p>
|
|
|
- {composeOptions.length > 0 && (
|
|
|
|
|
|
|
+ {canCompose && (
|
|
|
<Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
|
|
<Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
{t('newMessage')}
|
|
{t('newMessage')}
|
|
@@ -580,7 +659,8 @@ export function UnifiedInbox({
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Which channel, asked only when there is a choice to make */}
|
|
|
|
|
|
|
+ {/* Which channel, asked when there is a choice to make or when email
|
|
|
|
|
+ has to explain that it is answered elsewhere */}
|
|
|
<Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
|
|
<Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
|
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogContent className="sm:max-w-sm">
|
|
|
<DialogHeader>
|
|
<DialogHeader>
|
|
@@ -603,6 +683,26 @@ export function UnifiedInbox({
|
|
|
{channelLabel(channel)}
|
|
{channelLabel(channel)}
|
|
|
</Button>
|
|
</Button>
|
|
|
))}
|
|
))}
|
|
|
|
|
+ {/* Shown, not hidden: a workshop that expects email here should
|
|
|
|
|
+ learn why it is missing rather than wonder. */}
|
|
|
|
|
+ {emailState === 'ownVendor' && (
|
|
|
|
|
+ <div className="space-y-1.5">
|
|
|
|
|
+ <Button type="button" variant="outline" className="w-full justify-start" disabled>
|
|
|
|
|
+ <ChannelBadge channel="email" withIcon className="mr-2" />
|
|
|
|
|
+ {channelLabel('email')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <p className="px-1 text-xs text-muted-foreground">
|
|
|
|
|
+ {te('ownVendorChoice')}
|
|
|
|
|
+ <Link
|
|
|
|
|
+ href="/settings/integrations"
|
|
|
|
|
+ className="ml-1 font-medium text-primary hover:underline"
|
|
|
|
|
+ onClick={() => setShowChannelChoice(false)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {te('ownVendorSettings')}
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
</DialogContent>
|
|
</DialogContent>
|
|
|
</Dialog>
|
|
</Dialog>
|
|
@@ -644,6 +744,12 @@ export function UnifiedInbox({
|
|
|
onOpenChange={(open) => !open && setComposeChannel(null)}
|
|
onOpenChange={(open) => !open && setComposeChannel(null)}
|
|
|
onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
|
|
onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
|
|
|
/>
|
|
/>
|
|
|
|
|
+
|
|
|
|
|
+ <NewEmailDialog
|
|
|
|
|
+ open={composeChannel === 'email'}
|
|
|
|
|
+ onOpenChange={(open) => !open && setComposeChannel(null)}
|
|
|
|
|
+ onSelect={(recipient) => openThreadFor('email', recipient)}
|
|
|
|
|
+ />
|
|
|
</>
|
|
</>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|