mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-26 06:19:36 +00:00
merges main into feat-whatsapp
This commit is contained in:
@@ -27,6 +27,11 @@ jobs:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
mailhog:
|
||||
image: mailhog/mailhog
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -42,7 +47,7 @@ jobs:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
run: npm install -g pnpm@9.15.3
|
||||
|
||||
- name: Install cypress deps
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
|
||||
@@ -62,6 +67,11 @@ jobs:
|
||||
- name: Install db schema and run tests
|
||||
env:
|
||||
LIBREDESK_SYSTEM_USER_PASSWORD: "StrongPass!123"
|
||||
# E2E runs over plain HTTP, so the Secure session/csrf cookies must be off
|
||||
# or real login + writes would be rejected.
|
||||
LIBREDESK_APP__SERVER__DISABLE_SECURE_COOKIES: "true"
|
||||
CYPRESS_SYSTEM_PASSWORD: "StrongPass!123"
|
||||
CYPRESS_MAILHOG_URL: "http://localhost:8025"
|
||||
run: |
|
||||
./libredesk --install --idempotent-install --yes --config ./config.toml
|
||||
./libredesk --upgrade --yes --config ./config.toml
|
||||
|
||||
@@ -46,10 +46,10 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18.12'
|
||||
node-version: "20"
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
run: npm install -g pnpm@9.15.3
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
|
||||
+15
-13
@@ -2,21 +2,23 @@
|
||||
|
||||
Thanks for your interest in contributing.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
- **All PRs must align with the project’s scope, goals, and technical direction.**
|
||||
- **Large or non-trivial PRs should be discussed first via an issue.**
|
||||
- Bug fixes and documentation improvements do **not** require prior discussion.
|
||||
- Please keep PRs focused on a single concern where possible.
|
||||
|
||||
## Process
|
||||
## Before you start
|
||||
|
||||
- **Propose first (for large changes)**
|
||||
Open an issue describing what you want to build, why it fits libredesk,
|
||||
and the high-level implementation approach.
|
||||
- All contributions should align with the project's scope, goals, and technical direction.
|
||||
- Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed.
|
||||
- Anything larger should start with an issue. See below for why.
|
||||
- Keep changes focused on a single concern wherever possible.
|
||||
|
||||
- **Keep PRs small**
|
||||
Smaller, well-scoped PRs are easier to review and test.
|
||||
Bundling multiple features into a single PR is discouraged.
|
||||
## Pull Requests
|
||||
|
||||
PRs are tricky. Any change, whether a new feature or a small tweak, has to fit the project's scope, goals, and technical direction. The code needs to match libredesk's existing style and conventions, and its impact on performance, usability, and stability should be clear before it can be merged.
|
||||
|
||||
That makes reviewing a slow, careful process. Larger PRs are harder to reason about, and reviewing them properly, discussing changes, and confirming they're safe to merge often takes more effort than writing the PR did. Whether a PR is accepted, and on what timeline, is ultimately up to the maintainers. This isn't personal. Maintainers carry the long-term context for the project, so decisions on direction and trade-offs sit with them.
|
||||
|
||||
To keep things moving:
|
||||
|
||||
1. **Propose first.** Open an issue describing what you want to build, why it fits libredesk, and how you plan to implement it, with relevant technical detail, before writing code. The change might not align with the project's direction, or it could already be in progress. Checking first saves everyone time.
|
||||
2. **Keep PRs small.** Send focused PRs with a clear, single scope wherever possible. Smaller PRs are easier to review and test. Bundling multiple features into one PR is strongly discouraged.
|
||||
|
||||
Thanks for helping keep libredesk focused and maintainable.
|
||||
|
||||
+3
-2
@@ -4,8 +4,9 @@ The open-source customer support platform you actually own. One inbox for email,
|
||||
|
||||
## Near Term
|
||||
- WhatsApp channel - WIP
|
||||
- Contact notes in conversation sidebar - WIP
|
||||
- Contact merging - WIP
|
||||
- Telegram channel - WIP
|
||||
- Contact merging - TODO
|
||||
- 2FA for password login - TODO
|
||||
- Deletable private notes - TODO
|
||||
- Help center with articles - TODO
|
||||
- AI assistants - TODO
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Security Reports
|
||||
|
||||
Report vulnerabilities privately via GitHub Security Advisories: https://github.com/abhinavxd/libredesk/security/advisories
|
||||
|
||||
## Threat model
|
||||
|
||||
Libredesk is **self-hosted and single-tenant**. Agents and admins are trusted internal staff. The only untrusted surfaces are the **livechat widget** (anonymous contacts) and **inbound email**.
|
||||
The permission system is a policy layer over already-trusted users, not a boundary between mutually distrusting parties. Admin permissions (`*:manage`, `*:read_all`) grant full control over their scope and are not granted by default.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- An admin exercising a documented admin capability (configuring webhooks, OIDC providers, automations, templates, inboxes, etc.). It is up to the operator to grant these capabilities only to trusted users. Defects in the admin code paths (sqli, RCE, auth bypass etc.) remain in scope.
|
||||
- SSRF via admin-configured outbound URLs (webhooks, OIDC provider discovery, etc.).
|
||||
|
||||
Anything else is in scope.
|
||||
+1
-1
@@ -15,7 +15,7 @@ func handleGetActivityLogs(r *fastglue.Request) error {
|
||||
total = 0
|
||||
)
|
||||
page, pageSize := getPagination(r)
|
||||
logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize)
|
||||
logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
|
||||
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(user.ID)
|
||||
|
||||
// Insert activity log.
|
||||
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ func handleGetContacts(r *fastglue.Request) error {
|
||||
total = 0
|
||||
)
|
||||
page, pageSize := getPagination(r)
|
||||
contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters)
|
||||
contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func handleDeleteContactNote(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/context_link/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -141,12 +141,11 @@ func handleGetContextLinkURL(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Verify agent has access to this conversation.
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
conv, err := enforceConversationAccess(app, conversationUUID, user)
|
||||
conv, err := enforceConversationAccess(app, conversationUUID, agent)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+51
-16
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -201,7 +202,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -320,7 +321,7 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -335,6 +336,40 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(conv)
|
||||
}
|
||||
|
||||
// handleDownloadConversationTranscript sends the conversation transcript as a text file download.
|
||||
func handleDownloadConversationTranscript(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
conversation, err := enforceConversationAccess(app, uuid, user)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
private := false
|
||||
messages, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing})
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now())
|
||||
safeRef := stringutil.SanitizeFilename(conversation.ReferenceNumber)
|
||||
filename := fmt.Sprintf("transcript-%s.txt", safeRef)
|
||||
r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
|
||||
r.RequestCtx.SetContentType("text/plain; charset=utf-8")
|
||||
r.RequestCtx.SetBody(transcript)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetContactPageVisits returns the recent page visits for the contact of a conversation.
|
||||
func handleGetContactPageVisits(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -343,7 +378,7 @@ func handleGetContactPageVisits(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -364,7 +399,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -399,7 +434,7 @@ func handleMarkConversationAsUnread(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -421,7 +456,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -450,7 +485,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -488,7 +523,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
|
||||
assigneeID := req.AssigneeID
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -533,7 +568,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -580,7 +615,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -636,7 +671,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -665,7 +700,7 @@ func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -695,7 +730,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -734,7 +769,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -755,7 +790,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -797,7 +832,7 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ func handleUpsertConversationDraft(r *fastglue.Request) error {
|
||||
req = draftReq{}
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func handleGetAllDrafts(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func handleDeleteConversationDraft(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/api/v1/conversations/{uuid}/page-visits", perm(handleGetContactPageVisits, "conversations:read"))
|
||||
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
|
||||
g.GET("/api/v1/conversations/{uuid}/transcript", perm(handleDownloadConversationTranscript, "messages:read"))
|
||||
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
|
||||
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
|
||||
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
|
||||
|
||||
@@ -600,6 +600,7 @@ func validateEmailConfig(app *App, configJSON json.RawMessage) error {
|
||||
func trimInboxFields(inb *imodels.Inbox) error {
|
||||
inb.Name = strings.TrimSpace(inb.Name)
|
||||
inb.From = strings.TrimSpace(inb.From)
|
||||
inb.FromNameTemplate = strings.TrimSpace(inb.FromNameTemplate)
|
||||
|
||||
// WhatsApp has no separate display-name field; the inbox name is the message From.
|
||||
if inb.Channel == whatsappChannel.ChannelWhatsApp && inb.From == "" {
|
||||
|
||||
+5
-1
@@ -405,7 +405,7 @@ func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager {
|
||||
|
||||
// initWS inits websocket hub.
|
||||
func initWS(user *user.Manager) *ws.Hub {
|
||||
return ws.NewHub(user)
|
||||
return ws.NewHub(initLogger("ws"), user)
|
||||
}
|
||||
|
||||
// getCustomStaticDir returns the custom static directory path from CLI flag or config.
|
||||
@@ -674,6 +674,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
|
||||
}
|
||||
|
||||
config.From = inboxRecord.From
|
||||
config.FromNameTemplate = inboxRecord.FromNameTemplate
|
||||
|
||||
if len(config.From) == 0 {
|
||||
log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name)
|
||||
@@ -700,6 +701,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS
|
||||
|
||||
inbox, err := email.New(msgStore, usrStore, email.Opts{
|
||||
ID: inboxRecord.ID,
|
||||
Name: inboxRecord.Name,
|
||||
Config: config,
|
||||
Lo: initLogger("email_inbox"),
|
||||
TokenRefreshCallback: tokenRefreshCallback,
|
||||
@@ -729,6 +731,7 @@ func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, u
|
||||
|
||||
inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
|
||||
ID: inboxRecord.ID,
|
||||
Name: inboxRecord.Name,
|
||||
Config: config,
|
||||
Lo: initLogger("livechat_inbox"),
|
||||
SignAvatarURL: signAvatarURL,
|
||||
@@ -752,6 +755,7 @@ func initWhatsAppInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, c
|
||||
|
||||
inb, err := whatsappChannel.New(msgStore, whatsappChannel.Opts{
|
||||
ID: inboxRecord.ID,
|
||||
Name: inboxRecord.Name,
|
||||
Config: config,
|
||||
From: inboxRecord.From,
|
||||
Client: client,
|
||||
|
||||
@@ -60,6 +60,7 @@ func handleLogin(r *fastglue.Request) error {
|
||||
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(user.ID)
|
||||
|
||||
// Insert activity log.
|
||||
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ func handleApplyMacro(r *fastglue.Request) error {
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
incomingActions = []autoModels.RuleAction{}
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+7
-4
@@ -50,6 +50,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/webhook"
|
||||
whatsappapi "github.com/abhinavxd/libredesk/internal/whatsapp"
|
||||
whatsappTemplate "github.com/abhinavxd/libredesk/internal/whatsapp_template"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -118,6 +119,7 @@ type App struct {
|
||||
whatsappIngester *WhatsAppIngester
|
||||
// WhatsApp inbox IDs whose Meta token was recently rejected, keyed to the last error time.
|
||||
whatsappAuthErrors sync.Map
|
||||
wsHub *ws.Hub
|
||||
|
||||
// Global state that stores data on an available app update.
|
||||
update *AppUpdate
|
||||
@@ -298,6 +300,7 @@ func main() {
|
||||
userNotification: userNotification,
|
||||
whatsappClient: waClient,
|
||||
whatsappTemplate: waTemplates,
|
||||
wsHub: wsHub,
|
||||
}
|
||||
app.consts.Store(constants)
|
||||
whatsappIngester, err := newWhatsAppIngester(app)
|
||||
@@ -328,10 +331,10 @@ func main() {
|
||||
|
||||
go func() {
|
||||
colorlog.Green("Server started at %s", ko.String("app.server.address"))
|
||||
if ko.String("server.socket") != "" {
|
||||
colorlog.Green("Unix socket created at %s", ko.String("server.socket"))
|
||||
if ko.String("app.server.socket") != "" {
|
||||
colorlog.Green("Unix socket created at %s", ko.String("app.server.socket"))
|
||||
}
|
||||
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
|
||||
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("app.server.socket"), s); err != nil {
|
||||
log.Fatalf("error starting server: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -379,7 +382,7 @@ func onUsersOffline(conv *conversation.Manager) func([]umodels.OfflineUser) {
|
||||
for _, u := range users {
|
||||
switch u.Type {
|
||||
case umodels.UserTypeAgent:
|
||||
conv.BroadcastAgentStatusToWidget(u.ID, umodels.Offline)
|
||||
conv.BroadcastAgentAvailability(u.ID, umodels.Offline)
|
||||
case umodels.UserTypeContact, umodels.UserTypeVisitor:
|
||||
conv.BroadcastContactUpdate(u.ID, map[string]any{"availability_status": umodels.Offline})
|
||||
}
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ func handleServeMedia(r *fastglue.Request) error {
|
||||
// Session/API key authenticated - perform full permission check.
|
||||
auser := r.RequestCtx.UserValue("user").(amodels.User)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+8
-4
@@ -52,7 +52,7 @@ func handleGetMessages(r *fastglue.Request) error {
|
||||
msgTypes = append(msgTypes, string(v))
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -125,6 +125,10 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if message.ConversationUUID != cuuid {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
|
||||
}
|
||||
|
||||
// Process CSAT status for the message (will only affect CSAT messages)
|
||||
messages := []cmodels.Message{message}
|
||||
app.conversation.ProcessCSATStatus(messages)
|
||||
@@ -155,7 +159,7 @@ func handleRetryMessage(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -190,7 +194,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
req = messageReq{}
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
+14
-5
@@ -43,13 +43,16 @@ func handleDeleteRole(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
userIDs, _ := app.user.GetUserIDsByRole(id)
|
||||
if err := app.role.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Invalidate all caches when role is deleted.
|
||||
app.user.InvalidateAllAgentCache()
|
||||
app.authz.InvalidateAllCache()
|
||||
|
||||
for _, uid := range userIDs {
|
||||
app.wsHub.KickUser(uid)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
@@ -94,12 +97,18 @@ func handleUpdateRole(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Log permission changes and invalidate caches.
|
||||
added, removed := role.ComparePermissions(oldRole.Permissions, updatedRole.Permissions)
|
||||
if len(added) > 0 || len(removed) > 0 {
|
||||
// Invalidate all caches when role permissions change.
|
||||
app.user.InvalidateAllAgentCache()
|
||||
app.authz.InvalidateAllCache()
|
||||
|
||||
if len(removed) > 0 {
|
||||
userIDs, err := app.user.GetUserIDsByRole(updatedRole.ID)
|
||||
if err == nil {
|
||||
for _, id := range userIDs {
|
||||
app.wsHub.KickUser(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.activityLog.RolePermissionsChanged(auser.ID, auser.Email, ip, updatedRole.ID, updatedRole.Name, added, removed); err != nil {
|
||||
app.lo.Error("error creating activity log", "error", err)
|
||||
|
||||
+70
-26
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
smodels "github.com/abhinavxd/libredesk/internal/search/models"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
@@ -13,45 +15,87 @@ const (
|
||||
|
||||
// handleSearchConversations searches conversations based on the query.
|
||||
func handleSearchConversations(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
wrapper := func(query string) (interface{}, error) {
|
||||
return app.search.Conversations(query)
|
||||
app, user, q, err := searchInputs(r)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return handleSearch(r, wrapper)
|
||||
results, err := app.search.Conversations(q)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
uuids := make([]string, len(results))
|
||||
for i, c := range results {
|
||||
uuids[i] = c.UUID
|
||||
}
|
||||
allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
|
||||
}
|
||||
set := uuidSet(allowed)
|
||||
out := make([]smodels.ConversationResult, 0, len(allowed))
|
||||
for _, c := range results {
|
||||
if _, ok := set[c.UUID]; ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleSearchMessages searches messages based on the query.
|
||||
func handleSearchMessages(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
wrapper := func(query string) (interface{}, error) {
|
||||
return app.search.Messages(query)
|
||||
app, user, q, err := searchInputs(r)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return handleSearch(r, wrapper)
|
||||
results, err := app.search.Messages(q)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
uuids := make([]string, len(results))
|
||||
for i, m := range results {
|
||||
uuids[i] = m.ConversationUUID
|
||||
}
|
||||
allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil))
|
||||
}
|
||||
set := uuidSet(allowed)
|
||||
out := make([]smodels.MessageResult, 0, len(allowed))
|
||||
for _, m := range results {
|
||||
if _, ok := set[m.ConversationUUID]; ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(out)
|
||||
}
|
||||
|
||||
// handleSearchContacts searches contacts based on the query.
|
||||
func handleSearchContacts(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
wrapper := func(query string) (interface{}, error) {
|
||||
return app.search.Contacts(query)
|
||||
app, _, q, err := searchInputs(r)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return handleSearch(r, wrapper)
|
||||
}
|
||||
|
||||
// handleSearch searches for the given query using the provided search function.
|
||||
func handleSearch(r *fastglue.Request, searchFunc func(string) (interface{}, error)) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
q = string(r.RequestCtx.QueryArgs().Peek("query"))
|
||||
)
|
||||
|
||||
if len(q) < minSearchQueryLength {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil))
|
||||
}
|
||||
|
||||
results, err := searchFunc(q)
|
||||
results, err := app.search.Contacts(q)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(results)
|
||||
}
|
||||
|
||||
func searchInputs(r *fastglue.Request) (*App, amodels.User, string, error) {
|
||||
app := r.Context.(*App)
|
||||
user, _ := r.RequestCtx.UserValue("user").(amodels.User)
|
||||
q := string(r.RequestCtx.QueryArgs().Peek("query"))
|
||||
if len(q) < minSearchQueryLength {
|
||||
return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil)
|
||||
}
|
||||
return app, user, q, nil
|
||||
}
|
||||
|
||||
func uuidSet(uuids []string) map[string]struct{} {
|
||||
s := make(map[string]struct{}, len(uuids))
|
||||
for _, u := range uuids {
|
||||
s[u] = struct{}{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
req.FaviconURL = strings.TrimSpace(req.FaviconURL)
|
||||
req.LogoURL = strings.TrimSpace(req.LogoURL)
|
||||
req.Timezone = strings.TrimSpace(req.Timezone)
|
||||
if req.Timezone != "" && !stringutil.IsValidTimezone(req.Timezone) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid timezone.", nil, envelope.InputError)
|
||||
}
|
||||
// Trim whitespace and trailing slash from root URL.
|
||||
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
|
||||
|
||||
|
||||
+12
-1
@@ -87,6 +87,14 @@ func handleUpdateTeam(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
members, err := app.team.GetMembers(id)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching team members for cache invalidation", "team_id", id, "error", err)
|
||||
} else {
|
||||
for _, m := range members {
|
||||
app.user.InvalidateAgentCache(m.ID)
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(updatedTeam)
|
||||
}
|
||||
|
||||
@@ -99,9 +107,12 @@ func handleDeleteTeam(r *fastglue.Request) error {
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
|
||||
}
|
||||
err = app.team.Delete(id)
|
||||
memberIDs, err := app.team.Delete(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
for _, mid := range memberIDs {
|
||||
app.user.InvalidateAgentCache(mid)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ func handleCreateTemplate(r *fastglue.Request) error {
|
||||
if req.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
|
||||
}
|
||||
if req.Type == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError)
|
||||
}
|
||||
template, err := app.tmpl.Create(req)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -77,6 +80,9 @@ func handleUpdateTemplate(r *fastglue.Request) error {
|
||||
if req.Name == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
|
||||
}
|
||||
if req.Type == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError)
|
||||
}
|
||||
updatedTemplate, err := app.tmpl.Update(id, req)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
|
||||
@@ -43,6 +43,7 @@ var migList = []migFunc{
|
||||
{"v2.0.0", migrations.V2_0_0},
|
||||
{"v2.2.0", migrations.V2_2_0},
|
||||
{"v2.3.0", migrations.V2_3_0},
|
||||
{"v2.4.0", migrations.V2_4_0},
|
||||
{"v3.0.0", migrations.V3_0_0},
|
||||
}
|
||||
|
||||
|
||||
+21
-10
@@ -101,7 +101,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -119,8 +119,9 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(auser.ID)
|
||||
|
||||
go app.conversation.BroadcastAgentStatusToWidget(auser.ID, availReq.Status)
|
||||
go app.conversation.BroadcastAgentAvailability(auser.ID, availReq.Status)
|
||||
|
||||
// Skip activity log when returning online from idle-away to avoid log spam.
|
||||
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
|
||||
@@ -129,7 +130,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
agent, err = app.user.GetAgent(auser.ID, "")
|
||||
agent, err = app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -166,7 +167,7 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
|
||||
// Upload avatar?
|
||||
if ok && len(files) > 0 {
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -176,7 +177,7 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Fetch updated agent and return.
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -278,8 +279,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Invalidate authz cache.
|
||||
defer app.authz.InvalidateUserCache(id)
|
||||
app.user.InvalidateAgentCache(id)
|
||||
app.wsHub.KickUser(id)
|
||||
|
||||
// Create activity log if user availability status changed.
|
||||
if oldAvailabilityStatus != req.AvailabilityStatus {
|
||||
@@ -330,6 +331,9 @@ func handleDeleteAgent(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
defer app.wsHub.KickUser(id)
|
||||
defer app.user.InvalidateAgentCache(id)
|
||||
|
||||
// Unassign all open conversations assigned to the user.
|
||||
if err := app.conversation.UnassignOpen(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -344,7 +348,7 @@ func handleGetCurrentAgent(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
u, err := app.user.GetAgent(auser.ID, "")
|
||||
u, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -359,7 +363,7 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
|
||||
)
|
||||
|
||||
// Get user
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
agent, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -379,6 +383,7 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
|
||||
if err = app.user.UpdateAvatar(agent.ID, ""); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(agent.ID)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -455,9 +460,12 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := app.user.ResetPassword(req.Token, req.Password); err != nil {
|
||||
id, err := app.user.ResetPassword(req.Token, req.Password)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(id)
|
||||
app.wsHub.KickUser(id)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
@@ -518,6 +526,7 @@ func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.
|
||||
if err := app.user.UpdateAvatar(user.ID, "/uploads/"+media.UUID); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -542,6 +551,7 @@ func handleGenerateAPIKey(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(user.ID)
|
||||
|
||||
// Return the API key and secret (only shown once)
|
||||
response := struct {
|
||||
@@ -575,6 +585,7 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
|
||||
if err := app.user.RevokeAPIKey(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.user.InvalidateAgentCache(id)
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
+14
-5
@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -47,6 +47,9 @@ func handleCreateUserView(r *fastglue.Request) error {
|
||||
if string(view.Filters) == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
|
||||
}
|
||||
if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
createdView, err := app.view.Create(view.Name, view.Filters, user.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -64,7 +67,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
|
||||
if err != nil || id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -96,7 +99,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -106,6 +109,9 @@ func handleUpdateUserView(r *fastglue.Request) error {
|
||||
if string(view.Filters) == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError)
|
||||
}
|
||||
if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
v, err := app.view.Get(id)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -127,7 +133,7 @@ func handleGetSharedViews(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -264,6 +270,9 @@ func validateSharedView(app *App, view vmodels.View) error {
|
||||
if string(view.Filters) == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil)
|
||||
}
|
||||
if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil {
|
||||
return err
|
||||
}
|
||||
if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
||||
ID: auser.ID,
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
Send: make(chan wsmodels.WSMessage, 256),
|
||||
Send: make(chan wsmodels.WSMessage, 128),
|
||||
}
|
||||
hub.AddClient(&c)
|
||||
go c.Listen()
|
||||
|
||||
+2
-2
@@ -170,8 +170,8 @@ func handleWidgetWS(r *fastglue.Request) error {
|
||||
continue
|
||||
}
|
||||
if userID > 0 {
|
||||
wasOffline := app.user.IsOffline(userID)
|
||||
if err := app.user.UpdateLastActive(userID); err != nil {
|
||||
wasOffline, err := app.user.UpdateLastActive(userID)
|
||||
if err != nil {
|
||||
app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err)
|
||||
} else if wasOffline {
|
||||
app.conversation.BroadcastContactUpdate(userID, map[string]any{"availability_status": "online"})
|
||||
|
||||
+3
-1
@@ -42,6 +42,8 @@ provider = "fs"
|
||||
[upload.fs]
|
||||
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
|
||||
upload_path = 'uploads'
|
||||
# Signed URL expiry duration (e.g., "30m", "1h"). Defaults to 1h if unset.
|
||||
expiry = "1h"
|
||||
|
||||
# S3 provider.
|
||||
[upload.s3]
|
||||
@@ -132,7 +134,7 @@ allowed_hosts = []
|
||||
# How often to check for conversations to unsnooze
|
||||
unsnooze_interval = "5m"
|
||||
# How long to keep drafts before deleting them from the database. (e.g. "360h", "48h")
|
||||
draft_retention_period = "360h"
|
||||
draft_retention_duration = "360h"
|
||||
# How often to check for offline conversations in database to send continuity emails
|
||||
continuity_scan_interval = "5m"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=1280, initial-scale=0.29, shrink-to-fit=no" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
|
||||
rel="stylesheet">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
:userTeams="userStore.teams"
|
||||
:userViews="userViews"
|
||||
:sharedViews="sharedViewStore.sharedViewList"
|
||||
@create-view="openCreateViewForm = true"
|
||||
@create-view="createView"
|
||||
@edit-view="editView"
|
||||
@delete-view="deleteView"
|
||||
@create-conversation="() => (openCreateConversationDialog = true)"
|
||||
@@ -191,9 +191,10 @@ watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path.startsWith('/inboxes') && path !== '/inboxes/search') {
|
||||
lastInboxPath.value = path.replace(/\/conversation\/[^/]+$/, '')
|
||||
lastInboxPath.value = path
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const userStore = useUserStore()
|
||||
const conversationStore = useConversationStore()
|
||||
@@ -258,6 +259,11 @@ const initStores = async () => {
|
||||
])
|
||||
}
|
||||
|
||||
const createView = () => {
|
||||
view.value = {}
|
||||
openCreateViewForm.value = true
|
||||
}
|
||||
|
||||
const editView = (v) => {
|
||||
view.value = { ...v }
|
||||
openCreateViewForm.value = true
|
||||
@@ -292,6 +298,7 @@ const getUserViews = async () => {
|
||||
|
||||
const initToaster = () => {
|
||||
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
|
||||
if (!message.description) return
|
||||
if (message.variant === 'destructive') {
|
||||
sooner.error(message.description)
|
||||
} else if (message.variant === 'warning') {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast as sooner } from 'vue-sonner'
|
||||
const emitter = useEmitter()
|
||||
|
||||
const toastHandler = (message) => {
|
||||
if (!message.description) return
|
||||
if (message.variant === 'destructive') {
|
||||
sooner.error(message.description)
|
||||
} else {
|
||||
|
||||
@@ -18,22 +18,31 @@ function getCSRFToken () {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Request interceptor.
|
||||
// Route-scoped abort, opt-in via { abortOnRoute: true }. Default no-abort protects in-flight saves.
|
||||
let routeAbort = new AbortController()
|
||||
export function abortRouteScope () {
|
||||
routeAbort.abort()
|
||||
routeAbort = new AbortController()
|
||||
}
|
||||
|
||||
http.interceptors.request.use((request) => {
|
||||
const token = getCSRFToken()
|
||||
if (token) {
|
||||
request.headers['X-CSRFTOKEN'] = token
|
||||
}
|
||||
|
||||
// Set content type for POST/PUT requests if the content type is not set.
|
||||
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
|
||||
request.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
|
||||
if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
|
||||
request.data = qs.stringify(request.data)
|
||||
}
|
||||
|
||||
|
||||
if (request.abortOnRoute && request.signal === undefined) {
|
||||
request.signal = routeAbort.signal
|
||||
}
|
||||
|
||||
return request
|
||||
})
|
||||
|
||||
@@ -323,15 +332,17 @@ const getConversationMessage = (cuuid, uuid) =>
|
||||
const retryMessage = (cuuid, uuid) =>
|
||||
http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
|
||||
const getConversationMessages = (uuid, params) =>
|
||||
http.get(`/api/v1/conversations/${uuid}/messages`, { params })
|
||||
http.get(`/api/v1/conversations/${uuid}/messages`, { params, abortOnRoute: true })
|
||||
const sendMessage = (uuid, data) =>
|
||||
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
|
||||
const getContactPageVisits = (uuid) => http.get(`/api/v1/conversations/${uuid}/page-visits`)
|
||||
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`, { abortOnRoute: true })
|
||||
const getConversationTranscript = (uuid) =>
|
||||
http.get(`/api/v1/conversations/${uuid}/transcript`, { responseType: 'blob' })
|
||||
const getContactPageVisits = (uuid) => http.get(`/api/v1/conversations/${uuid}/page-visits`, { abortOnRoute: true })
|
||||
const getAllMacros = () => http.get('/api/v1/macros')
|
||||
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
|
||||
const createMacro = (data) =>
|
||||
@@ -354,15 +365,17 @@ const applyMacro = (uuid, id, data) =>
|
||||
}
|
||||
})
|
||||
const getTeamUnassignedConversations = (teamID, params) =>
|
||||
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
|
||||
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
|
||||
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params, abortOnRoute: true })
|
||||
const getAssignedConversations = (params) =>
|
||||
http.get('/api/v1/conversations/assigned', { params, abortOnRoute: true })
|
||||
const getUnassignedConversations = (params) =>
|
||||
http.get('/api/v1/conversations/unassigned', { params })
|
||||
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
|
||||
http.get('/api/v1/conversations/unassigned', { params, abortOnRoute: true })
|
||||
const getAllConversations = (params) =>
|
||||
http.get('/api/v1/conversations/all', { params, abortOnRoute: true })
|
||||
const getMentionedConversations = (params) =>
|
||||
http.get('/api/v1/conversations/mentioned', { params })
|
||||
http.get('/api/v1/conversations/mentioned', { params, abortOnRoute: true })
|
||||
const getViewConversations = (id, params) =>
|
||||
http.get(`/api/v1/views/${id}/conversations`, { params })
|
||||
http.get(`/api/v1/views/${id}/conversations`, { params, abortOnRoute: true })
|
||||
const uploadMedia = (data) =>
|
||||
http.post('/api/v1/media', data, {
|
||||
headers: {
|
||||
@@ -574,6 +587,7 @@ export default {
|
||||
getOverviewTagDistribution,
|
||||
getConversationMessage,
|
||||
getConversationMessages,
|
||||
getConversationTranscript,
|
||||
getCurrentUser,
|
||||
getCurrentUserTeams,
|
||||
getAllMacros,
|
||||
|
||||
@@ -16,10 +16,18 @@
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<!--USER -->
|
||||
<Avatar v-if="type === 'user'" class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div v-if="type === 'user'" class="relative">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<StatusDot
|
||||
v-if="item.availability_status"
|
||||
:status="item.availability_status"
|
||||
size="sm"
|
||||
class="absolute bottom-0 right-0 border border-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Others -->
|
||||
<span v-else-if="item.emoji">{{ item.emoji }}</span>
|
||||
@@ -35,10 +43,18 @@
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div v-if="selected" class="flex items-center gap-2 min-w-0">
|
||||
<!--USER -->
|
||||
<Avatar v-if="type === 'user'" class="w-7 h-7 shrink-0">
|
||||
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div v-if="type === 'user'" class="relative shrink-0">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<StatusDot
|
||||
v-if="selected.availability_status"
|
||||
:status="selected.availability_status"
|
||||
size="sm"
|
||||
class="absolute bottom-0 right-0 border border-background"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Others -->
|
||||
<span v-else-if="selected.emoji" class="shrink-0">{{ selected.emoji }}</span>
|
||||
@@ -59,6 +75,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
|
||||
import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
|
||||
import StatusDot from '@shared-ui/components/StatusDot.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
:title="t('filter.toggleConnector')"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
{{ modelValue === LOGIC.OR ? t('admin.automation.or') : t('admin.automation.and') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { LOGIC } from '@/constants/filterConfig'
|
||||
|
||||
const modelValue = defineModel('modelValue', { required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const toggle = () => {
|
||||
modelValue.value = modelValue.value === LOGIC.OR ? LOGIC.AND : LOGIC.OR
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="max-h-[50vh] overflow-y-auto pr-1 pb-2 space-y-2">
|
||||
<template v-for="(grp, gi) in modelValue.rules" :key="grp.__id">
|
||||
<div v-if="gi > 0" class="flex justify-center">
|
||||
<ConnectorToggle :modelValue="modelValue.logic" @update:modelValue="setLogic" />
|
||||
</div>
|
||||
<FilterGroupCard
|
||||
:modelValue="grp"
|
||||
:fields="fields"
|
||||
:canRemove="modelValue.rules.length > 1"
|
||||
@update:modelValue="updateGroup(gi, $event)"
|
||||
@remove="removeGroup(gi)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="modelValue.rules.length >= MAX_FILTER_GROUPS"
|
||||
@click.stop="addGroup"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
{{ t('filter.addGroup') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import FilterGroupCard from '@/components/filter/FilterGroupCard.vue'
|
||||
import ConnectorToggle from '@/components/filter/ConnectorToggle.vue'
|
||||
import {
|
||||
createRoot,
|
||||
createGroup,
|
||||
normalizeToTwoLevel,
|
||||
isStrictTwoLevel
|
||||
} from '@/components/filter/filterTree'
|
||||
import { MAX_FILTER_GROUPS } from '@/constants/filterConfig'
|
||||
|
||||
// vee-validate's componentField carries onInput/onChange listeners; without this they fall
|
||||
// through to the root div and bubbled keystrokes overwrite the whole filters value.
|
||||
defineOptions({ inheritAttrs: false })
|
||||
defineProps({
|
||||
fields: { type: Array, required: true }
|
||||
})
|
||||
const modelValue = defineModel('modelValue', { default: () => createRoot() })
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
(v) => {
|
||||
if (!isStrictTwoLevel(v)) modelValue.value = normalizeToTwoLevel(v)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const setLogic = (logic) => {
|
||||
modelValue.value = { ...modelValue.value, logic }
|
||||
}
|
||||
|
||||
const updateGroup = (index, group) => {
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
rules: modelValue.value.rules.map((g, i) => (i === index ? group : g))
|
||||
}
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
if (modelValue.value.rules.length >= MAX_FILTER_GROUPS) return
|
||||
modelValue.value = { ...modelValue.value, rules: [...modelValue.value.rules, createGroup()] }
|
||||
}
|
||||
|
||||
const removeGroup = (index) => {
|
||||
let rules = modelValue.value.rules.filter((_, i) => i !== index)
|
||||
if (rules.length === 0) rules = [createGroup()]
|
||||
modelValue.value = { ...modelValue.value, rules }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
|
||||
<div v-if="canRemove" class="flex justify-end">
|
||||
<CloseButton :aria-label="t('filter.removeGroup')" :onClose="() => emit('remove')">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</CloseButton>
|
||||
</div>
|
||||
|
||||
<template v-for="(rule, index) in group.rules" :key="rule.__id">
|
||||
<ConnectorToggle
|
||||
v-if="index > 0"
|
||||
:modelValue="group.logic"
|
||||
@update:modelValue="setLogic"
|
||||
/>
|
||||
<FilterRow
|
||||
:modelValue="rule"
|
||||
:fields="fields"
|
||||
@update:modelValue="updateRule(index, $event)"
|
||||
@remove="removeRule(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-foreground"
|
||||
@click.stop="addCondition"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
{{ t('actions.addCondition') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import FilterRow from '@/components/filter/FilterRow.vue'
|
||||
import ConnectorToggle from '@/components/filter/ConnectorToggle.vue'
|
||||
import { createLeaf } from '@/components/filter/filterTree'
|
||||
|
||||
defineProps({
|
||||
fields: { type: Array, required: true },
|
||||
canRemove: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['remove'])
|
||||
const group = defineModel('modelValue', { required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const setLogic = (logic) => {
|
||||
group.value = { ...group.value, logic }
|
||||
}
|
||||
|
||||
const updateRule = (index, rule) => {
|
||||
group.value = { ...group.value, rules: group.value.rules.map((r, i) => (i === index ? rule : r)) }
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
group.value = { ...group.value, rules: [...group.value.rules, createLeaf()] }
|
||||
}
|
||||
|
||||
const removeRule = (index) => {
|
||||
const rules = group.value.rules.filter((_, i) => i !== index)
|
||||
if (rules.length === 0) {
|
||||
emit('remove')
|
||||
return
|
||||
}
|
||||
group.value = { ...group.value, rules }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="group flex items-center gap-3">
|
||||
<div class="flex gap-2 w-full">
|
||||
<div
|
||||
class="flex-1 rounded-md"
|
||||
:class="[shake && missingField && 'animate-shake', showInvalid && missingField && 'ring-1 ring-destructive']"
|
||||
>
|
||||
<Select :model-value="modelValue.field" @update:model-value="onFieldChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('placeholders.selectField')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
|
||||
{{ field.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 rounded-md"
|
||||
:class="[shake && missingOperator && 'animate-shake', showInvalid && missingOperator && 'ring-1 ring-destructive']"
|
||||
>
|
||||
<Select
|
||||
v-if="modelValue.field"
|
||||
:model-value="modelValue.operator"
|
||||
@update:model-value="onOperatorChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('placeholders.selectOperator')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="op in fieldOperators" :key="op" :value="op">
|
||||
{{ opLabel(op) }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 rounded-md"
|
||||
:class="[shake && missingValue && 'animate-shake', showInvalid && missingValue && 'ring-1 ring-destructive']"
|
||||
>
|
||||
<div v-if="modelValue.field && modelValue.operator">
|
||||
<template v-if="modelValue.operator !== OPERATOR.SET && modelValue.operator !== OPERATOR.NOT_SET">
|
||||
<SelectTag
|
||||
v-if="fieldType === FIELD_TYPE.MULTI_SELECT"
|
||||
v-model="leafValue"
|
||||
:items="fieldOptions"
|
||||
:placeholder="t('placeholders.selectTags')"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_user_id'"
|
||||
v-model="leafValue"
|
||||
:items="fieldOptions"
|
||||
:placeholder="t('placeholders.selectValue')"
|
||||
type="user"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_team_id'"
|
||||
v-model="leafValue"
|
||||
:items="fieldOptions"
|
||||
:placeholder="t('placeholders.selectValue')"
|
||||
type="team"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="fieldOptions.length > 0"
|
||||
v-model="leafValue"
|
||||
:items="fieldOptions"
|
||||
:placeholder="t('placeholders.selectValue')"
|
||||
/>
|
||||
|
||||
<DateFilterValue
|
||||
v-else-if="fieldType === FIELD_TYPE.DATE"
|
||||
v-model="leafValue"
|
||||
:range="modelValue.operator === OPERATOR.BETWEEN"
|
||||
/>
|
||||
|
||||
<Input v-else v-model="leafValue" :placeholder="t('globals.terms.value')" type="text" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CloseButton type="button" :onClose="() => emit('remove')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, ref, watch, onUnmounted, nextTick } from 'vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@shared-ui/components/ui/select'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FIELD_TYPE, OPERATOR, operatorLabel } from '@/constants/filterConfig'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
|
||||
import DateFilterValue from '@/components/filter/DateFilterValue.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['remove'])
|
||||
const modelValue = defineModel('modelValue', { required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const fieldConfig = computed(() => props.fields.find((f) => f.field === modelValue.value.field))
|
||||
const fieldOptions = computed(() => fieldConfig.value?.options || [])
|
||||
const fieldOperators = computed(() => fieldConfig.value?.operators || [])
|
||||
const fieldType = computed(() => fieldConfig.value?.type || '')
|
||||
|
||||
// "contains any of" only applies to multi-value fields; single-value text stays "contains"
|
||||
const opLabel = (op) => (fieldType.value === FIELD_TYPE.MULTI_SELECT ? operatorLabel(op, t) : op)
|
||||
|
||||
const isEmptyValue = (v) =>
|
||||
v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
|
||||
const missingField = computed(() => !modelValue.value.field)
|
||||
const missingOperator = computed(() => !!modelValue.value.field && !modelValue.value.operator)
|
||||
const needsValue = computed(
|
||||
() =>
|
||||
!!modelValue.value.operator &&
|
||||
modelValue.value.operator !== OPERATOR.SET &&
|
||||
modelValue.value.operator !== OPERATOR.NOT_SET
|
||||
)
|
||||
const missingValue = computed(() => needsValue.value && isEmptyValue(modelValue.value.value))
|
||||
const invalid = computed(() => missingField.value || missingOperator.value || missingValue.value)
|
||||
|
||||
const validateTick = inject('filterValidateTick', ref(0))
|
||||
const showInvalid = computed(() => validateTick.value > 0)
|
||||
const shake = ref(false)
|
||||
let shakeTimer = null
|
||||
watch(validateTick, async () => {
|
||||
if (!invalid.value) return
|
||||
shake.value = false
|
||||
await nextTick()
|
||||
shake.value = true
|
||||
clearTimeout(shakeTimer)
|
||||
shakeTimer = setTimeout(() => {
|
||||
shake.value = false
|
||||
}, 500)
|
||||
})
|
||||
onUnmounted(() => clearTimeout(shakeTimer))
|
||||
|
||||
// All edits emit a fresh leaf; the tree is never mutated in place.
|
||||
const patch = (changes) => {
|
||||
modelValue.value = { ...modelValue.value, ...changes }
|
||||
}
|
||||
|
||||
const leafValue = computed({
|
||||
get: () => modelValue.value.value,
|
||||
set: (value) => patch({ value })
|
||||
})
|
||||
|
||||
const onFieldChange = (field) => {
|
||||
const config = props.fields.find((f) => f.field === field)
|
||||
patch({
|
||||
field,
|
||||
model: config?.model || '',
|
||||
operator: '',
|
||||
value: config?.type === FIELD_TYPE.MULTI_SELECT ? [] : ''
|
||||
})
|
||||
}
|
||||
|
||||
const onOperatorChange = (operator) => {
|
||||
if (modelValue.value.operator === operator) return
|
||||
if (modelValue.value.operator === OPERATOR.BETWEEN || operator === OPERATOR.BETWEEN) {
|
||||
patch({ operator, value: '' })
|
||||
return
|
||||
}
|
||||
patch({ operator })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FIELD_TYPE, LOGIC, OPERATOR } from '@/constants/filterConfig'
|
||||
|
||||
let _seq = 0
|
||||
const uid = () => `f${++_seq}`
|
||||
|
||||
export const createLeaf = () => ({ __id: uid(), model: '', field: '', operator: '', value: '' })
|
||||
|
||||
export const createGroup = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createLeaf()] })
|
||||
|
||||
export const createRoot = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createGroup()] })
|
||||
|
||||
export const isGroupNode = (node) =>
|
||||
!!node && (Array.isArray(node.rules) || typeof node.logic === 'string')
|
||||
|
||||
// Strict two-level shape: an object root holding only groups, each group holding only leaves.
|
||||
export const isStrictTwoLevel = (node) =>
|
||||
!!node &&
|
||||
typeof node === 'object' &&
|
||||
!Array.isArray(node) &&
|
||||
Array.isArray(node.rules) &&
|
||||
node.rules.length > 0 &&
|
||||
node.rules.every(
|
||||
(g) => isGroupNode(g) && Array.isArray(g.rules) && g.rules.every((r) => !isGroupNode(r))
|
||||
)
|
||||
|
||||
export const collectLeaves = (node) =>
|
||||
isGroupNode(node) ? (node.rules || []).flatMap(collectLeaves) : [node]
|
||||
|
||||
export const isPartialLeaf = (leaf) =>
|
||||
!leaf.field ||
|
||||
!leaf.operator ||
|
||||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(leaf.operator) &&
|
||||
(leaf.value === undefined ||
|
||||
leaf.value === null ||
|
||||
leaf.value === '' ||
|
||||
(Array.isArray(leaf.value) && leaf.value.length === 0)))
|
||||
|
||||
const keyed = (node) => ({ __id: node.__id || uid(), ...node })
|
||||
|
||||
const toStringIdArray = (value) => {
|
||||
if (Array.isArray(value)) return value.map((v) => String(v))
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.map((v) => String(v)) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const withIds = (node) => {
|
||||
if (isGroupNode(node)) {
|
||||
return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(withIds) }
|
||||
}
|
||||
return keyed(node)
|
||||
}
|
||||
|
||||
// normalizeToTwoLevel coerces any stored shape (legacy flat array, old nested tree, new two-level)
|
||||
// into a strict { logic, rules: [ {logic, rules:[leaf,...]}, ... ] }: the top level holds only groups,
|
||||
// and each group holds only leaves. Existing groups are preserved; loose leaves are collected into one
|
||||
// group so semantics (e.g. status AND (high OR low)) survive the coercion.
|
||||
export const normalizeToTwoLevel = (filters) => {
|
||||
if (Array.isArray(filters)) {
|
||||
return withIds({ logic: LOGIC.AND, rules: [{ logic: LOGIC.AND, rules: filters.map((f) => ({ ...f })) }] })
|
||||
}
|
||||
if (!isGroupNode(filters)) return createRoot()
|
||||
|
||||
const outerLogic = filters.logic || LOGIC.AND
|
||||
const topRules = filters.rules || []
|
||||
const groups = []
|
||||
const looseLeaves = []
|
||||
for (const r of topRules) {
|
||||
if (isGroupNode(r)) {
|
||||
groups.push({ logic: r.logic || LOGIC.AND, rules: collectLeaves(r).map((l) => ({ ...l })) })
|
||||
} else {
|
||||
looseLeaves.push({ ...r })
|
||||
}
|
||||
}
|
||||
if (looseLeaves.length) groups.unshift({ logic: outerLogic, rules: looseLeaves })
|
||||
if (groups.length === 0) groups.push({ logic: LOGIC.AND, rules: [createLeaf()] })
|
||||
return withIds({ logic: outerLogic, rules: groups })
|
||||
}
|
||||
|
||||
// serializeFilterTree drops UI-only __id and converts multi-select array values to JSON strings of numeric IDs.
|
||||
export const serializeFilterTree = (node) => {
|
||||
if (isGroupNode(node)) {
|
||||
return { logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(serializeFilterTree) }
|
||||
}
|
||||
const leaf = { model: node.model, field: node.field, operator: node.operator, value: node.value }
|
||||
if (Array.isArray(leaf.value)) {
|
||||
leaf.value = JSON.stringify(
|
||||
leaf.value.map((v) => {
|
||||
const num = Number(v)
|
||||
return isNaN(num) ? v : num
|
||||
})
|
||||
)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
// deserializeFilterTree restores multi-select string values to string-ID arrays and keeps __id for stable keys.
|
||||
export const deserializeFilterTree = (node, fields) => {
|
||||
if (isGroupNode(node)) {
|
||||
return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map((n) => deserializeFilterTree(n, fields)) }
|
||||
}
|
||||
const field = fields.find((f) => f.field === node.field)
|
||||
if (field?.type === FIELD_TYPE.MULTI_SELECT) {
|
||||
return keyed({ ...node, value: toStringIdArray(node.value) })
|
||||
}
|
||||
return keyed(node)
|
||||
}
|
||||
@@ -78,6 +78,68 @@ export function useConversationFilters () {
|
||||
label: t('globals.terms.createdAt'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
waiting_since: {
|
||||
label: t('globals.terms.waitingSince'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
snoozed_until: {
|
||||
label: t('globals.terms.snoozedUntil'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
last_message_at: {
|
||||
label: t('globals.terms.lastMessageAt'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
last_interaction_at: {
|
||||
label: t('globals.terms.lastInteractionAt'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
next_sla_deadline_at: {
|
||||
label: t('globals.terms.nextSlaDeadline'),
|
||||
type: FIELD_TYPE.DATE,
|
||||
operators: FIELD_OPERATORS.DATE
|
||||
},
|
||||
email: {
|
||||
label: t('globals.terms.contactEmail'),
|
||||
type: FIELD_TYPE.TEXT,
|
||||
operators: FIELD_OPERATORS.TEXT,
|
||||
model: 'users'
|
||||
},
|
||||
external_user_id: {
|
||||
label: t('globals.terms.contactExternalId'),
|
||||
type: FIELD_TYPE.TEXT,
|
||||
operators: FIELD_OPERATORS.TEXT_EXACT,
|
||||
model: 'users'
|
||||
},
|
||||
last_interaction_sender: {
|
||||
label: t('globals.terms.lastInteractionBy'),
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: [
|
||||
{ label: t('globals.terms.contact'), value: 'contact' },
|
||||
{ label: t('globals.terms.agent'), value: 'agent' }
|
||||
]
|
||||
},
|
||||
sla_policy_id: {
|
||||
label: t('globals.terms.slaPolicy'),
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: slaStore.options
|
||||
},
|
||||
channel: {
|
||||
label: t('globals.terms.channel'),
|
||||
type: FIELD_TYPE.SELECT,
|
||||
operators: FIELD_OPERATORS.SELECT,
|
||||
options: [
|
||||
{ label: t('globals.terms.email'), value: 'email' },
|
||||
{ label: t('globals.terms.liveChat'), value: 'livechat' }
|
||||
],
|
||||
model: 'inboxes'
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useStorage, useDebounceFn, useThrottleFn } from '@vueuse/core'
|
||||
|
||||
export function useIdleDetection () {
|
||||
const userStore = useUserStore()
|
||||
const AWAY_THRESHOLD = 4 * 60 * 1000
|
||||
const CHECK_INTERVAL = 30 * 1000
|
||||
const AWAY_THRESHOLD = 5 * 60 * 1000 // 5 min
|
||||
const CHECK_INTERVAL = 30 * 1000 // 30 sec
|
||||
|
||||
const lastActivity = useStorage('last_active', Date.now())
|
||||
const timer = ref(null)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export const LOGIC = {
|
||||
AND: 'AND',
|
||||
OR: 'OR'
|
||||
}
|
||||
|
||||
// Mirrors dbutil.MaxFilterGroups on the backend.
|
||||
export const MAX_FILTER_GROUPS = 10
|
||||
|
||||
export const FIELD_TYPE = {
|
||||
SELECT: 'select',
|
||||
TAG: 'tag',
|
||||
@@ -21,6 +29,17 @@ export const OPERATOR = {
|
||||
BETWEEN: 'between'
|
||||
}
|
||||
|
||||
// operatorLabel returns a clearer display label for operators whose meaning is ambiguous with
|
||||
// multiple values (contains = matches ANY of the values). Other operators display as-is.
|
||||
const OPERATOR_LABEL_KEYS = {
|
||||
[OPERATOR.CONTAINS]: 'filter.containsAnyOf',
|
||||
[OPERATOR.NOT_CONTAINS]: 'filter.containsNoneOf'
|
||||
}
|
||||
export const operatorLabel = (op, t) => {
|
||||
const key = OPERATOR_LABEL_KEYS[op]
|
||||
return key ? t(key) : op
|
||||
}
|
||||
|
||||
export const FIELD_OPERATORS = {
|
||||
SELECT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET],
|
||||
BOOLEAN: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS],
|
||||
@@ -32,6 +51,8 @@ export const FIELD_OPERATORS = {
|
||||
OPERATOR.CONTAINS,
|
||||
OPERATOR.NOT_CONTAINS
|
||||
],
|
||||
// For text columns that do not support partial matching, only allow exact match operators.
|
||||
TEXT_EXACT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET],
|
||||
DATE: [
|
||||
OPERATOR.EQUALS,
|
||||
OPERATOR.NOT_EQUALS,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export const WS_EVENT = {
|
||||
NEW_MESSAGE: 'new_message',
|
||||
NEW_CONVERSATION: 'new_conversation',
|
||||
MESSAGE_UPDATE: 'message_update',
|
||||
CONVERSATION_UPDATE: 'conversation_update',
|
||||
CONTACT_UPDATE: 'contact_update',
|
||||
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
|
||||
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
|
||||
LIST_SUBSCRIBE_REPLACE: 'list_subscribe_replace',
|
||||
TYPING: 'typing',
|
||||
NEW_NOTIFICATION: 'new_notification',
|
||||
AGENT_AVAILABILITY_UPDATE: 'agent_availability_update',
|
||||
}
|
||||
|
||||
// Message types that should not be queued because they become stale quickly
|
||||
|
||||
@@ -384,9 +384,9 @@ const isAPIKeyLoading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
|
||||
teams.value = teamsResp.value.data.data
|
||||
roles.value = rolesResp.value.data.data
|
||||
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeamsCompact(), api.getRoles()])
|
||||
if (teamsResp.status === 'fulfilled') teams.value = teamsResp.value.data.data
|
||||
if (rolesResp.status === 'fulfilled') roles.value = rolesResp.value.data.data
|
||||
} catch (err) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
:key="key"
|
||||
:value="op"
|
||||
>
|
||||
{{ op }}
|
||||
{{ operatorLabel(op, t) }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -221,6 +221,7 @@ import { Input } from '@shared-ui/components/ui/input'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConversationFilters } from '../../../composables/useConversationFilters'
|
||||
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
|
||||
import { operatorLabel } from '@/constants/filterConfig'
|
||||
|
||||
const props = defineProps({
|
||||
ruleGroup: {
|
||||
|
||||
@@ -28,6 +28,25 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-if="showFormFields" v-slot="{ componentField }" name="from_name_template">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.fromNameTemplate') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="t('admin.inbox.fromNameTemplate.placeholder')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{{ $t('admin.inbox.fromNameTemplate.description') }}
|
||||
<br />
|
||||
{{ $t('admin.inbox.fromNameTemplate.variables') }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-if="showFormFields" v-slot="{ componentField }" name="reply_to">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('admin.inbox.replyToAddress') }}</FormLabel>
|
||||
@@ -869,6 +888,7 @@ const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
from: '',
|
||||
from_name_template: '',
|
||||
reply_to: '',
|
||||
enabled: true,
|
||||
csat_enabled: false,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import * as z from 'zod'
|
||||
import { isGoDuration, validateEmail } from '@shared-ui/utils/string'
|
||||
import { isGoDuration, validateEmail, isValidTemplate } from '@shared-ui/utils/string'
|
||||
import { AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2 } from '@main/constants/auth.js'
|
||||
|
||||
const FROM_NAME_TEMPLATE_VARS = ['.Agent.FirstName', '.Agent.LastName', '.Agent.FullName', '.Inbox.Name']
|
||||
|
||||
export const createFormSchema = (t) => z.object({
|
||||
name: z.string().min(1, t('globals.messages.required')),
|
||||
from: z.string().min(1, t('globals.messages.required')),
|
||||
from_name_template: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('')
|
||||
.refine((val) => isValidTemplate(val, FROM_NAME_TEMPLATE_VARS), {
|
||||
message: t('admin.inbox.fromNameTemplate.invalidTemplate')
|
||||
}),
|
||||
reply_to: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Spinner v-if="formLoading"></Spinner>
|
||||
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormField v-slot="{ componentField }" name="name" :validate-on-blur="false">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.terms.name') }}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -16,7 +16,7 @@
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.terms.filter', 2) }}</FormLabel>
|
||||
<FormControl>
|
||||
<FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
|
||||
<FilterGroupBuilder :fields="filterFields" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>{{ t('view.form.filters.description') }}</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -73,7 +73,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, provide } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
@@ -87,10 +87,17 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@shared-ui/components/ui/form'
|
||||
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
|
||||
import FilterGroupBuilder from '@/components/filter/FilterGroupBuilder.vue'
|
||||
import {
|
||||
normalizeToTwoLevel,
|
||||
serializeFilterTree,
|
||||
deserializeFilterTree,
|
||||
collectLeaves,
|
||||
isPartialLeaf,
|
||||
createRoot
|
||||
} from '@/components/filter/filterTree'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { OPERATOR, FIELD_TYPE } from '@/constants/filterConfig.js'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import {
|
||||
Select,
|
||||
@@ -106,6 +113,8 @@ import { z } from 'zod'
|
||||
const { conversationsListFilters } = useConversationFilters()
|
||||
const { t } = useI18n()
|
||||
const formLoading = ref(false)
|
||||
const validateTick = ref(0)
|
||||
provide('filterValidateTick', validateTick)
|
||||
const tStore = useTeamStore()
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
@@ -135,7 +144,7 @@ const submitLabel = computed(() => {
|
||||
|
||||
const filterFields = computed(() =>
|
||||
Object.entries(conversationsListFilters.value).map(([field, value]) => ({
|
||||
model: 'conversations',
|
||||
model: value.model || 'conversations',
|
||||
label: value.label,
|
||||
field,
|
||||
type: value.type,
|
||||
@@ -154,22 +163,12 @@ const formSchema = toTypedSchema(
|
||||
.min(2, { message: t('view.form.name.length') })
|
||||
.max(140, { message: t('view.form.name.length') }),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
model: z.string().optional(),
|
||||
field: z.string().optional(),
|
||||
operator: z.string().optional(),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.union([z.string(), z.number()]))
|
||||
])
|
||||
.optional()
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
.object({
|
||||
logic: z.string().optional(),
|
||||
rules: z.array(z.any()).optional()
|
||||
})
|
||||
.passthrough()
|
||||
.default(() => createRoot()),
|
||||
visibility: z.enum(['all', 'team']),
|
||||
team_id: z.string().nullable().optional()
|
||||
})
|
||||
@@ -185,52 +184,31 @@ const formSchema = toTypedSchema(
|
||||
const form = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
visibility: props.initialValues.visibility || 'all'
|
||||
visibility: props.initialValues.visibility || 'all',
|
||||
filters: createRoot()
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
// Make sure at least one filter is selected
|
||||
if (!values.filters || values.filters.length === 0) {
|
||||
const leaves = collectLeaves(values.filters)
|
||||
if (leaves.length === 0) {
|
||||
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for partial filters
|
||||
const hasPartialFilters = values.filters.some(
|
||||
(f) =>
|
||||
!f.field ||
|
||||
!f.operator ||
|
||||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) &&
|
||||
(!f.value || (Array.isArray(f.value) && f.value.length === 0)))
|
||||
)
|
||||
if (hasPartialFilters) {
|
||||
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
|
||||
if (leaves.some(isPartialLeaf)) {
|
||||
validateTick.value++
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize array values to JSON strings for backend
|
||||
if (values.filters) {
|
||||
values.filters = values.filters.map((filter) => {
|
||||
if (Array.isArray(filter.value)) {
|
||||
const numericValues = filter.value.map((v) => {
|
||||
const num = Number(v)
|
||||
return isNaN(num) ? v : num
|
||||
})
|
||||
return { ...filter, value: JSON.stringify(numericValues) }
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
const payload = { ...values, filters: serializeFilterTree(values.filters) }
|
||||
|
||||
// Clear team_id if visibility is 'all', otherwise convert to number
|
||||
if (values.visibility === 'all') {
|
||||
values.team_id = null
|
||||
if (payload.visibility === 'all') {
|
||||
payload.team_id = null
|
||||
} else {
|
||||
values.team_id = values.team_id ? Number(values.team_id) : null
|
||||
payload.team_id = payload.team_id ? Number(payload.team_id) : null
|
||||
}
|
||||
|
||||
props.submitForm(values)
|
||||
props.submitForm(payload)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -238,25 +216,11 @@ watch(
|
||||
(newValues) => {
|
||||
if (Object.keys(newValues).length === 0) return
|
||||
|
||||
// Deserialize multi-select filter values from JSON strings to arrays
|
||||
const processedVal = { ...newValues }
|
||||
if (processedVal.filters) {
|
||||
processedVal.filters = processedVal.filters.map((filter) => {
|
||||
const field = filterFields.value.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField && typeof filter.value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filter.value)
|
||||
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
|
||||
return { ...filter, value: stringValues }
|
||||
} catch (e) {
|
||||
return filter
|
||||
}
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
processedVal.filters = deserializeFilterTree(
|
||||
normalizeToTwoLevel(newValues.filters),
|
||||
filterFields.value
|
||||
)
|
||||
|
||||
// Convert team_id to string for the select component
|
||||
if (processedVal.team_id) {
|
||||
|
||||
@@ -289,17 +289,38 @@ const visibleMacros = computed(() => {
|
||||
return matched
|
||||
})
|
||||
|
||||
const { Meta_K, Ctrl_K } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
function preventDefaultOnHotkey(key) {
|
||||
return (e) => {
|
||||
if (e.key === key && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { Meta_K, Ctrl_K } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired: preventDefaultOnHotkey('k')
|
||||
})
|
||||
|
||||
watch([Meta_K, Ctrl_K], ([mac, win]) => {
|
||||
if (mac || win) toggleOpen()
|
||||
if (mac || win) {
|
||||
if (nestedCommand.value !== 'apply-macro-to-new-conversation') setNestedCommand(null)
|
||||
toggleOpen()
|
||||
}
|
||||
})
|
||||
|
||||
const { Meta_M, Ctrl_M } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired: preventDefaultOnHotkey('m')
|
||||
})
|
||||
|
||||
watch([Meta_M, Ctrl_M], ([mac, win]) => {
|
||||
if (mac || win) {
|
||||
if (nestedCommand.value !== 'apply-macro-to-new-conversation') {
|
||||
setNestedCommand('apply-macro-to-existing-conversation')
|
||||
}
|
||||
toggleOpen()
|
||||
}
|
||||
})
|
||||
|
||||
const highlightedMacro = ref(null)
|
||||
@@ -338,9 +359,6 @@ const otherActions = computed(
|
||||
)
|
||||
|
||||
function toggleOpen() {
|
||||
if (nestedCommand.value != 'apply-macro-to-new-conversation' && !open.value) {
|
||||
nestedCommand.value = null
|
||||
}
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="startAddingNote"
|
||||
v-if="!isAddingNote && !isLoading && notes.length !== 0 && userStore.can('contact_notes:write')"
|
||||
v-if="!isAddingNote && !isLoading && (notes.length !== 0 || compact) && userStore.can('contact_notes:write')"
|
||||
class="transition-all hover:bg-primary/10 hover:border-primary/30"
|
||||
>
|
||||
<PlusIcon size="18" />
|
||||
@@ -17,8 +17,12 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="h-52" v-if="isLoading">
|
||||
<Spinner />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center"
|
||||
:class="compact ? 'py-4' : 'h-52'"
|
||||
>
|
||||
<Spinner :absolute="false" />
|
||||
</div>
|
||||
|
||||
<!-- Note input -->
|
||||
@@ -129,34 +133,40 @@
|
||||
</div>
|
||||
|
||||
<!-- No notes message -->
|
||||
<div
|
||||
v-if="notes.length === 0 && !isAddingNote && !isLoading"
|
||||
class="box border-dashed p-10 text-center bg-muted/50 mt-6"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="rounded-full bg-muted p-4 mb-2">
|
||||
<MessageSquareIcon class="text-muted-foreground" size="25" />
|
||||
</div>
|
||||
<h3 class="mt-2 text-base font-medium text-foreground">
|
||||
{{ $t('contact.notes.empty') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
{{ $t('contact.notes.help') }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="userStore.can('contact_notes:write')"
|
||||
variant="outline"
|
||||
class="mt-3"
|
||||
@click="startAddingNote"
|
||||
>
|
||||
<PlusIcon size="15" />
|
||||
{{ $t('contact.addNote') }}
|
||||
</Button>
|
||||
<template v-if="showEmpty">
|
||||
<div v-if="compact" class="text-center text-sm text-muted-foreground py-4">
|
||||
{{ $t('contact.notes.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="box border-dashed p-10 text-center bg-muted/50 mt-6">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="rounded-full bg-muted p-4 mb-2">
|
||||
<MessageSquareIcon class="text-muted-foreground" size="25" />
|
||||
</div>
|
||||
<h3 class="mt-2 text-base font-medium text-foreground">
|
||||
{{ $t('contact.notes.empty') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
{{ $t('contact.notes.help') }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="userStore.can('contact_notes:write')"
|
||||
variant="outline"
|
||||
class="mt-3"
|
||||
@click="startAddingNote"
|
||||
>
|
||||
<PlusIcon size="15" />
|
||||
{{ $t('contact.addNote') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const notesCache = new Map()
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
@@ -201,14 +211,22 @@ const NOTES_LIMIT = 10
|
||||
const showAll = ref(false)
|
||||
const latestFetchId = ref(0)
|
||||
|
||||
|
||||
const fetchNotes = async (contactId = props.contactId) => {
|
||||
const fetchNotes = async (contactId = props.contactId, { useCache = true } = {}) => {
|
||||
if (!contactId) return
|
||||
const fetchId = ++latestFetchId.value
|
||||
|
||||
if (useCache && notesCache.has(contactId)) {
|
||||
notes.value = notesCache.get(contactId)
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const { data } = await api.getContactNotes(contactId)
|
||||
if (fetchId !== latestFetchId.value) return
|
||||
notes.value = data.data
|
||||
notesCache.set(contactId, data.data)
|
||||
} catch (error) {
|
||||
if (fetchId !== latestFetchId.value) return
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -235,9 +253,12 @@ const cancelAddNote = () => {
|
||||
}
|
||||
|
||||
const addOrUpdateNote = async () => {
|
||||
const targetId = props.contactId
|
||||
if (!targetId) return
|
||||
try {
|
||||
await api.createContactNote(props.contactId, { note: newNote.value })
|
||||
await fetchNotes()
|
||||
await api.createContactNote(targetId, { note: newNote.value })
|
||||
notesCache.delete(targetId)
|
||||
await fetchNotes(props.contactId, { useCache: false })
|
||||
cancelAddNote()
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -248,15 +269,18 @@ const addOrUpdateNote = async () => {
|
||||
}
|
||||
|
||||
const deleteNote = async (noteId) => {
|
||||
const targetId = props.contactId
|
||||
if (!targetId) return
|
||||
try {
|
||||
await api.deleteContactNote(props.contactId, noteId)
|
||||
await api.deleteContactNote(targetId, noteId)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
await fetchNotes()
|
||||
notesCache.delete(targetId)
|
||||
await fetchNotes(props.contactId, { useCache: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,16 +289,18 @@ const visibleNotes = computed(() => {
|
||||
return notes.value.slice(0, NOTES_LIMIT)
|
||||
})
|
||||
|
||||
const showEmpty = computed(() => notes.value.length === 0 && !isAddingNote.value && !isLoading.value)
|
||||
|
||||
watch(() => props.contactId, (newId) => {
|
||||
latestFetchId.value++
|
||||
showAll.value = false
|
||||
cancelAddNote()
|
||||
notes.value = []
|
||||
if (!newId) {
|
||||
notes.value = []
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
if (!notesCache.has(newId)) notes.value = []
|
||||
fetchNotes(newId)
|
||||
}, { immediate: true })
|
||||
|
||||
</script>
|
||||
|
||||
@@ -3,23 +3,19 @@
|
||||
<!-- Header -->
|
||||
<div class="h-12 flex-shrink-0 px-2 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<span v-if="!conversationStore.conversation.loading">
|
||||
{{ conversationStore.currentContactName }}
|
||||
</span>
|
||||
<Skeleton class="w-[130px] h-6" v-else />
|
||||
<span>{{ conversationStore.currentContactName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
v-if="conversationStore.current?.status"
|
||||
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded text-sm"
|
||||
v-if="!conversationStore.conversation.loading"
|
||||
>
|
||||
<span class="text-secondary font-medium inline-block">
|
||||
{{ conversationStore.current?.status }}
|
||||
</span>
|
||||
</div>
|
||||
<Skeleton class="w-[70px] h-6 rounded-full" v-else />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
@@ -31,6 +27,18 @@
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="w-8 h-8 p-0">
|
||||
<MoreHorizontal class="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="downloadTranscript">
|
||||
{{ t('conversation.downloadTranscript') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,12 +54,14 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '../../stores/conversation'
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@shared-ui/components/ui/dropdown-menu'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import MessageList from '@/features/conversation/message/MessageList.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import WhatsAppComposer from './WhatsAppComposer.vue'
|
||||
@@ -59,9 +69,40 @@ import { WHATSAPP_CHANNEL } from '@main/features/conversation/whatsappTemplate'
|
||||
import { EMITTER_EVENTS } from '../../constants/emitterEvents.js'
|
||||
import { CONVERSATION_DEFAULT_STATUSES } from '../../constants/conversation'
|
||||
import { useEmitter } from '../../composables/useEmitter'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import api from '@main/api'
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const downloadTranscript = async () => {
|
||||
const conversation = conversationStore.current
|
||||
if (!conversation) return
|
||||
try {
|
||||
const response = await api.getConversationTranscript(conversation.uuid)
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `transcript-${conversation.reference_number}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
} catch (error) {
|
||||
if (error.response?.data instanceof Blob) {
|
||||
try {
|
||||
error.response.data = JSON.parse(await error.response.data.text())
|
||||
} catch {
|
||||
// keep the original blob, handleHTTPError falls back to a generic message
|
||||
}
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isWhatsAppChannel = computed(
|
||||
() => conversationStore.current?.inbox_channel === WHATSAPP_CHANNEL
|
||||
|
||||
@@ -287,6 +287,7 @@ const emailQuery = ref('')
|
||||
const conversationStore = useConversationStore()
|
||||
const macroStore = useMacroStore()
|
||||
let timeoutId = null
|
||||
let previousMacroView = ''
|
||||
const insertContent = ref('')
|
||||
const selectedContact = ref(null)
|
||||
const emailInputRef = ref(null)
|
||||
@@ -333,6 +334,7 @@ onUnmounted(() => {
|
||||
clearTimeout(timeoutId)
|
||||
clearMediaFiles()
|
||||
conversationStore.resetMacro(MACRO_CONTEXT.NEW_CONVERSATION)
|
||||
macroStore.setCurrentView(previousMacroView)
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
|
||||
command: null,
|
||||
open: false
|
||||
@@ -340,6 +342,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
previousMacroView = macroStore.currentView
|
||||
macroStore.setCurrentView('starting_conversation')
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
|
||||
command: 'apply-macro-to-new-conversation',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction @click="processSend(true, true)">{{
|
||||
<AlertDialogAction @click="processSend(true, true, deferredStatus)">{{
|
||||
$t('replyBox.sendAnyway')
|
||||
}}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -30,7 +30,7 @@
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
|
||||
<AlertDialogAction @click="processSend(false, true)">{{
|
||||
<AlertDialogAction @click="processSend(false, true, deferredStatus)">{{
|
||||
$t('replyBox.sendAnyway')
|
||||
}}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -103,6 +103,7 @@
|
||||
v-model:mentions="mentions"
|
||||
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
|
||||
@send="processSend"
|
||||
@sendAndSetStatus="processSendAndSetStatus"
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@@ -137,6 +138,7 @@
|
||||
v-model:mentions="mentions"
|
||||
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
|
||||
@send="processSend"
|
||||
@sendAndSetStatus="processSendAndSetStatus"
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@@ -159,6 +161,7 @@ import api from '@main/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { useInboxStore } from '@main/stores/inbox'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -242,28 +245,15 @@ const cc = ref('')
|
||||
const bcc = ref('')
|
||||
const showBcc = ref(false)
|
||||
const emailErrors = ref([])
|
||||
const aiPrompts = ref([])
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const aiPrompts = computed(() => aiPromptStore.prompts)
|
||||
const replyBoxContentRef = ref(null)
|
||||
const showContactEmailWarning = ref(false)
|
||||
const showMissingTagsWarning = ref(false)
|
||||
const deferredStatus = ref(null)
|
||||
const mentions = ref([])
|
||||
|
||||
/**
|
||||
* Fetches AI prompts from the server.
|
||||
*/
|
||||
const fetchAiPrompts = async () => {
|
||||
try {
|
||||
const resp = await api.getAiPrompts()
|
||||
aiPrompts.value = resp.data.data
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fetchAiPrompts()
|
||||
aiPromptStore.fetchPrompts()
|
||||
|
||||
/**
|
||||
* Handles the AI prompt selection event.
|
||||
@@ -319,10 +309,7 @@ const hasTextContent = computed(() => {
|
||||
return textContent.value.trim().length > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Processes the send action.
|
||||
*/
|
||||
const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck = false) => {
|
||||
const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck = false, statusToSet = null) => {
|
||||
let hasMessageSendingErrored = false
|
||||
isEditorFullscreen.value = false
|
||||
|
||||
@@ -341,6 +328,7 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
|
||||
currentInbox?.prompt_tags_on_reply &&
|
||||
!(conversationStore.current.tags?.length > 0)
|
||||
) {
|
||||
deferredStatus.value = statusToSet
|
||||
showMissingTagsWarning.value = true
|
||||
return
|
||||
}
|
||||
@@ -376,6 +364,7 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
|
||||
.map((e) => e.trim())
|
||||
.includes(contactEmail)
|
||||
) {
|
||||
deferredStatus.value = statusToSet
|
||||
showContactEmailWarning.value = true
|
||||
return
|
||||
}
|
||||
@@ -486,10 +475,13 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
|
||||
clearMediaFiles()
|
||||
emailErrors.value = []
|
||||
mentions.value = []
|
||||
if (statusToSet) conversationStore.updateStatus(statusToSet)
|
||||
}
|
||||
isSending.value = false
|
||||
}
|
||||
|
||||
const processSendAndSetStatus = (status) => processSend(false, false, status)
|
||||
|
||||
/**
|
||||
* Watches for changes in the conversation's macro id and update message content.
|
||||
*/
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
:isSending="isSending"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
:handleSendAndSetStatus="handleSendAndSetStatus"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
@@ -239,6 +240,7 @@ const props = defineProps({
|
||||
const emit = defineEmits([
|
||||
'toggleFullscreen',
|
||||
'send',
|
||||
'sendAndSetStatus',
|
||||
'fileUpload',
|
||||
'inlineImageUpload',
|
||||
'fileDelete',
|
||||
@@ -302,21 +304,34 @@ const validateEmails = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the reply or private note
|
||||
*/
|
||||
const handleSend = async () => {
|
||||
const validateBeforeSend = async () => {
|
||||
await validateEmails()
|
||||
if (emailErrors.value.length > 0) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: t('globals.messages.correctEmailErrors')
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the reply or private note
|
||||
*/
|
||||
const handleSend = async () => {
|
||||
if (!(await validateBeforeSend())) return
|
||||
emit('send')
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the reply or private note and set conversation status
|
||||
*/
|
||||
const handleSendAndSetStatus = async (status) => {
|
||||
if (!(await validateBeforeSend())) return
|
||||
emit('sendAndSetStatus', status)
|
||||
}
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
emit('fileUpload', event)
|
||||
}
|
||||
|
||||
@@ -48,9 +48,37 @@
|
||||
<WhatsAppIcon class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending" v-if="showSendButton">
|
||||
{{ $t('globals.messages.send') }}
|
||||
</Button>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="h-8 px-4 rounded-r-none"
|
||||
@click="handleSend"
|
||||
:disabled="!enableSend"
|
||||
:isLoading="isSending"
|
||||
v-if="showSendButton"
|
||||
>
|
||||
{{ $t('globals.messages.send') }}
|
||||
</Button>
|
||||
<DropdownMenu v-if="showSendButton">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
class="h-8 px-2 rounded-l-none border-l border-primary-foreground/30 [&[data-state=open]>svg]:rotate-180"
|
||||
:disabled="!enableSend"
|
||||
>
|
||||
<ChevronDownIcon class="text-primary-foreground transition-transform" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{{ $t('replyBox.sendAndSetAs') }}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="status in conversationStore.statusOptionsNoSnooze"
|
||||
:key="status.value"
|
||||
@click="handleSendAndSetStatus(status.label)"
|
||||
>
|
||||
{{ status.label }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,7 +87,14 @@ import { ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Toggle } from '@shared-ui/components/ui/toggle'
|
||||
import { Paperclip, Smile } from 'lucide-vue-next'
|
||||
import { Paperclip, Smile, ChevronDownIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel
|
||||
} from '@shared-ui/components/ui/dropdown-menu'
|
||||
import WhatsAppIcon from '@main/components/icons/WhatsAppIcon.vue'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { useEmitter } from '@main/composables/useEmitter'
|
||||
@@ -86,6 +121,7 @@ defineProps({
|
||||
isSending: Boolean,
|
||||
enableSend: Boolean,
|
||||
handleSend: Function,
|
||||
handleSendAndSetStatus: Function,
|
||||
showSendButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<!-- Content -->
|
||||
<div class="flex-grow overflow-y-auto">
|
||||
<EmptyList
|
||||
v-if="!hasConversations && !hasErrored && !isLoading"
|
||||
v-if="showEmpty"
|
||||
key="empty"
|
||||
class="px-4 py-8"
|
||||
:title="t('conversation.noConversationsFound')"
|
||||
@@ -83,9 +83,8 @@
|
||||
:icon="MessageCircleQuestion"
|
||||
/>
|
||||
|
||||
<!-- Error State -->
|
||||
<EmptyList
|
||||
v-if="conversationStore.conversations.errorMessage"
|
||||
v-if="hasErrored"
|
||||
key="error"
|
||||
class="px-4 py-8"
|
||||
:title="t('conversation.couldNotFetch')"
|
||||
@@ -93,7 +92,6 @@
|
||||
:icon="MessageCircleWarning"
|
||||
/>
|
||||
|
||||
<!-- Empty State -->
|
||||
<TransitionGroup
|
||||
enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
@@ -102,9 +100,8 @@
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform translate-y-4"
|
||||
>
|
||||
<!-- Conversation List -->
|
||||
<div
|
||||
v-if="!conversationStore.conversations.errorMessage"
|
||||
v-if="!hasErrored && !conversationStore.conversations.loading"
|
||||
key="list"
|
||||
class="divide-y divide-border"
|
||||
:class="{ 'border-b border-border': hasConversations }"
|
||||
@@ -119,9 +116,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="isLoading" key="loading" class="space-y-4">
|
||||
<ConversationListItemSkeleton v-for="index in 5" :key="index" />
|
||||
<div v-if="conversationStore.conversations.loading" key="loading">
|
||||
<ConversationListItemSkeleton v-for="i in 12" :key="i" :index="i - 1" />
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
@@ -133,15 +129,15 @@
|
||||
<Button
|
||||
v-if="conversationStore.conversations.hasMore"
|
||||
variant="outline"
|
||||
@click="loadNextPage"
|
||||
:disabled="isLoading"
|
||||
@click="conversationStore.fetchNextConversations"
|
||||
:disabled="conversationStore.conversations.fetching"
|
||||
class="transition-all duration-200 ease-in-out transform hover:scale-105"
|
||||
>
|
||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ isLoading ? t('globals.terms.loading') : t('globals.terms.loadMore') }}
|
||||
<Loader2 v-if="conversationStore.conversations.fetching" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ conversationStore.conversations.fetching ? t('globals.terms.loading') : t('globals.terms.loadMore') }}
|
||||
</Button>
|
||||
<p
|
||||
class="text-sm text-gray-500"
|
||||
class="text-sm text-muted-foreground"
|
||||
v-else-if="conversationStore.conversationsList.length > 10"
|
||||
>
|
||||
{{ $t('conversation.allLoaded') }}
|
||||
@@ -196,11 +192,13 @@ const handleSortChange = (order) => {
|
||||
conversationStore.setListSortField(order)
|
||||
}
|
||||
|
||||
const loadNextPage = () => {
|
||||
conversationStore.fetchNextConversations()
|
||||
}
|
||||
|
||||
const hasConversations = computed(() => conversationStore.conversationsList.length !== 0)
|
||||
const hasErrored = computed(() => !!conversationStore.conversations.errorMessage)
|
||||
const isLoading = computed(() => conversationStore.conversations.loading)
|
||||
const showEmpty = computed(
|
||||
() =>
|
||||
!hasConversations.value &&
|
||||
!hasErrored.value &&
|
||||
!conversationStore.conversations.loading &&
|
||||
conversationStore.conversations.initialized
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -84,7 +84,10 @@
|
||||
<!-- Message preview + unread count -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm flex-1 min-w-0 truncate text-muted-foreground">
|
||||
<template v-if="hasDraftForConversation">
|
||||
<template v-if="isTyping">
|
||||
<span class="italic text-primary">{{ $t('globals.terms.typing') }}</span>
|
||||
</template>
|
||||
<template v-else-if="hasDraftForConversation">
|
||||
<span class="font-medium text-primary">{{ $t('globals.terms.draft') }}:</span>
|
||||
{{ draftPreview }}
|
||||
</template>
|
||||
@@ -195,9 +198,9 @@ const CHANNEL_ICONS = {
|
||||
const channelIcon = computed(() => CHANNEL_ICONS[props.conversation.inbox_channel] || Mail)
|
||||
|
||||
const conversationRoute = computed(() => {
|
||||
const baseRoute = route.name.includes('team')
|
||||
const baseRoute = route.params.teamID
|
||||
? 'team-inbox-conversation'
|
||||
: route.name.includes('view')
|
||||
: route.params.viewID
|
||||
? 'view-inbox-conversation'
|
||||
: 'inbox-conversation'
|
||||
return {
|
||||
@@ -243,6 +246,8 @@ const hasDraftForConversation = computed(() => {
|
||||
return conversationStore.hasDraft(props.conversation.uuid)
|
||||
})
|
||||
|
||||
const isTyping = computed(() => conversationStore.typingByUUID[props.conversation.uuid] === true)
|
||||
|
||||
const draftPreview = computed(() => {
|
||||
const draft = conversationStore.getDraft(props.conversation.uuid)
|
||||
if (!draft?.content) return ''
|
||||
|
||||
@@ -1,14 +1,74 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-3 py-3 border-b min-w-[200px]">
|
||||
<Skeleton class="h-10 w-10 rounded-full aspect-square" />
|
||||
<div class="space-y-1 flex-grow">
|
||||
<Skeleton class="h-3.5 w-full max-w-[250px]" />
|
||||
<Skeleton class="h-3 w-full max-w-[180px]" />
|
||||
<Skeleton class="h-3 w-full max-w-[140px]" />
|
||||
<div class="flex items-start gap-2 px-3 py-3 border-b border-border">
|
||||
<div class="relative flex-shrink-0 w-10 h-10">
|
||||
<div class="skel-bar h-10 w-10 rounded-full" />
|
||||
<span class="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center">
|
||||
<span class="skel-bar w-2 h-2 rounded-full" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-2 pt-0.5">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="flex items-baseline gap-1.5 min-w-0">
|
||||
<div class="skel-bar h-3.5 rounded-sm" :style="{ width: nameW }" />
|
||||
<div class="skel-bar h-3 rounded-sm" :style="{ width: inboxW }" />
|
||||
</div>
|
||||
<div class="skel-bar h-3 w-9 rounded-sm flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<div v-if="hasSubject" class="skel-bar h-3 rounded-sm" :style="{ width: subjectW }" />
|
||||
|
||||
<div class="flex items-center justify-between gap-2 pt-0.5">
|
||||
<div class="skel-bar h-3 rounded-sm flex-1" :style="{ maxWidth: previewW }" />
|
||||
<div v-if="hasUnread" class="skel-bar h-5 w-5 rounded-full flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<div v-if="slaBadges.length" class="flex items-center gap-1 pt-0.5">
|
||||
<span
|
||||
v-for="(w, n) in slaBadges"
|
||||
:key="n"
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-border"
|
||||
>
|
||||
<span class="skel-bar w-2.5 h-2.5 rounded-full" />
|
||||
<span class="skel-bar h-2 rounded-sm" :style="{ width: w }" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
index: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const nameWidths = ['96px', '128px', '108px', '142px', '88px']
|
||||
const inboxWidths = ['52px', '38px', '60px', '46px', '54px']
|
||||
const subjectWidths = ['72%', '88%', '58%', '92%', '66%']
|
||||
const previewWidths = ['100%', '78%', '92%', '64%', '86%']
|
||||
const subjectFlags = [true, false, true, true, false]
|
||||
const unreadFlags = [true, false, false, true, false]
|
||||
const slaPatterns = [['28px'], [], ['24px', '32px'], [], ['30px']]
|
||||
|
||||
const i = computed(() => props.index % 5)
|
||||
const nameW = computed(() => nameWidths[i.value])
|
||||
const inboxW = computed(() => inboxWidths[i.value])
|
||||
const subjectW = computed(() => subjectWidths[i.value])
|
||||
const previewW = computed(() => previewWidths[i.value])
|
||||
const hasSubject = computed(() => subjectFlags[i.value])
|
||||
const hasUnread = computed(() => unreadFlags[i.value])
|
||||
const slaBadges = computed(() => slaPatterns[i.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skel-bar {
|
||||
background-color: hsl(var(--muted));
|
||||
animation: skel-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skel-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div v-if="show" class="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 w-[calc(100%-2rem)] max-w-md">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 shadow-lg">
|
||||
<span class="flex-1 text-sm text-foreground">{{ $t('conversation.assignSelfPrompt') }}</span>
|
||||
<Button size="sm" @click="$emit('assign')">{{ $t('conversation.assignSelfAction') }}</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
:aria-label="$t('globals.messages.close')"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['assign', 'dismiss'])
|
||||
</script>
|
||||
@@ -7,8 +7,8 @@
|
||||
</div>
|
||||
|
||||
<div v-if="csatResponse.rating" class="flex items-center gap-2 mb-2">
|
||||
<span class="text-lg">{{ getRatingEmoji(csatResponse.rating) }}</span>
|
||||
<span class="text-sm font-medium">{{ getRatingText(csatResponse.rating) }}</span>
|
||||
<span class="text-lg">{{ csatRatingEmoji(csatResponse.rating) }}</span>
|
||||
<span class="text-sm font-medium">{{ t(csatRatingTextKey(csatResponse.rating)) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ csatResponse.rating }}/5</span>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { csatRatingEmoji, csatRatingTextKey } from '@shared-ui/utils/csat.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -53,20 +54,4 @@ const csatResponse = computed(() => {
|
||||
const hasResponse = computed(() =>
|
||||
csatResponse.value && (csatResponse.value.rating || csatResponse.value.feedback)
|
||||
)
|
||||
|
||||
const getRatingEmoji = (rating) => {
|
||||
const emojis = { 1: '😢', 2: '😕', 3: '😊', 4: '😃', 5: '🤩' }
|
||||
return emojis[rating] || ''
|
||||
}
|
||||
|
||||
const getRatingText = (rating) => {
|
||||
const keys = {
|
||||
1: 'globals.terms.poor',
|
||||
2: 'globals.terms.fair',
|
||||
3: 'globals.terms.good',
|
||||
4: 'globals.terms.great',
|
||||
5: 'globals.terms.excellent'
|
||||
}
|
||||
return keys[rating] ? t(keys[rating]) : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
<template>
|
||||
<div class="flex flex-col relative h-full">
|
||||
<div ref="threadEl" class="flex-1 overflow-y-auto" @scroll="handleScroll">
|
||||
<div class="min-h-full px-4 pb-10">
|
||||
<div ref="threadEl" class="flex-1 overflow-y-auto [overflow-anchor:none]" @scroll="handleScroll">
|
||||
<div ref="contentEl" class="min-h-full px-4 pb-10 relative">
|
||||
<div
|
||||
v-if="showLoadMore"
|
||||
class="text-center mt-3"
|
||||
v-if="
|
||||
conversationStore.currentConversationHasMoreMessages &&
|
||||
!conversationStore.messages.loading
|
||||
"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="conversationStore.fetchNextMessages"
|
||||
class="transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 hover:scale-105 active:scale-95"
|
||||
@click="loadMore"
|
||||
:disabled="conversationStore.messages.fetching"
|
||||
class="transition-all duration-200 hover:bg-accent hover:scale-105 active:scale-95"
|
||||
>
|
||||
<RefreshCw size="17" class="mr-2" />
|
||||
<Loader2
|
||||
v-if="conversationStore.messages.fetching"
|
||||
size="17"
|
||||
class="mr-2 animate-spin"
|
||||
/>
|
||||
<RefreshCw v-else size="17" class="mr-2" />
|
||||
{{ $t('globals.terms.loadMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
|
||||
|
||||
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div">
|
||||
<TransitionGroup v-else enter-active-class="animate-slide-in" leave-active-class="message-leaving" tag="div">
|
||||
<div
|
||||
v-for="row in messageRows"
|
||||
:key="row.message.uuid"
|
||||
:data-message-uuid="row.message.uuid"
|
||||
:class="[row.spacingClass, { 'my-2': row.message.type === 'activity' }]"
|
||||
>
|
||||
<DaySeparator
|
||||
v-if="row.showDaySeparator"
|
||||
:date="row.message.created_at"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div v-if="!row.message.private && row.message.type !== 'activity'">
|
||||
<MessageBubble
|
||||
:message="row.message"
|
||||
@@ -37,7 +45,7 @@
|
||||
:group-with-next="row.groupWithNext"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isPrivateNote(row.message)">
|
||||
<div v-else-if="row.message.type === 'outgoing' && row.message.private">
|
||||
<MessageBubble
|
||||
:message="row.message"
|
||||
direction="outgoing"
|
||||
@@ -60,173 +68,155 @@
|
||||
|
||||
<!-- Sticky container for the scroll arrow -->
|
||||
<ScrollToBottomButton
|
||||
:is-at-bottom="isAtBottom"
|
||||
:is-at-bottom="!hasUserScrolled"
|
||||
:unread-count="unReadMessages"
|
||||
@scroll-to-bottom="handleScrollToBottom"
|
||||
/>
|
||||
|
||||
<!-- Nudge to self-assign after replying to an unassigned conversation -->
|
||||
<AssignSelfNudge
|
||||
:show="showAssignNudge"
|
||||
@assign="assignToSelf"
|
||||
@dismiss="showAssignNudge = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import ActivityMessageBubble from './ActivityMessageBubble.vue'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { useUserStore } from '@main/stores/user'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { RefreshCw, ChevronDown } from 'lucide-vue-next'
|
||||
import { RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton'
|
||||
import DaySeparator from '@shared-ui/components/DaySeparator'
|
||||
import { isSameDay } from 'date-fns'
|
||||
import AssignSelfNudge from './AssignSelfNudge.vue'
|
||||
import { useEmitter } from '@main/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
|
||||
import MessagesSkeleton from './MessagesSkeleton.vue'
|
||||
import { TypingIndicator } from '@shared-ui/components/TypingIndicator'
|
||||
import { useStickyScroll } from '@shared-ui/composables'
|
||||
|
||||
const MENTION_TOP_OFFSET_RATIO = 0.25
|
||||
const MENTION_SETTLE_FRAMES = 3
|
||||
const MENTION_MAX_ANCHOR_FRAMES = 90
|
||||
const HIGHLIGHT_MS = 2500
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const userStore = useUserStore()
|
||||
const threadEl = ref(null)
|
||||
const contentEl = ref(null)
|
||||
const emitter = useEmitter()
|
||||
const isAtBottom = ref(true)
|
||||
const unReadMessages = ref(0)
|
||||
const currentConversationUUID = ref('')
|
||||
const showAssignNudge = ref(false)
|
||||
let currentConversationUUID = ''
|
||||
let openScrollDone = false
|
||||
|
||||
const checkIfAtBottom = () => {
|
||||
const thread = threadEl.value
|
||||
if (thread) {
|
||||
const tolerance = 100
|
||||
const isBottom = thread.scrollHeight - thread.scrollTop - thread.clientHeight <= tolerance
|
||||
isAtBottom.value = isBottom
|
||||
}
|
||||
const assignToSelf = () => {
|
||||
conversationStore.updateAssignee('user', { assignee_id: userStore.userID })
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
checkIfAtBottom()
|
||||
}
|
||||
const { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll } = useStickyScroll(threadEl, contentEl, {
|
||||
onArriveBottom: () => { unReadMessages.value = 0 }
|
||||
})
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
hasUserScrolled.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
const thread = threadEl.value
|
||||
if (thread) {
|
||||
thread.scrollTop = thread.scrollHeight
|
||||
checkIfAtBottom()
|
||||
const applyOpenScroll = () => {
|
||||
const thread = threadEl.value
|
||||
if (!thread) return
|
||||
const targetUUID = route.query.scrollTo
|
||||
const targetEl = targetUUID ? thread.querySelector(`[data-message-uuid="${targetUUID}"]`) : null
|
||||
if (targetEl) {
|
||||
hasUserScrolled.value = true
|
||||
// Messages above the target collapse to max-h after mount, so re-pin until offsetTop stops moving.
|
||||
let lastOffset = -1
|
||||
let stableFrames = 0
|
||||
let frames = 0
|
||||
const anchorToTarget = () => {
|
||||
if (!threadEl.value || !targetEl.isConnected) return
|
||||
const offset = targetEl.offsetTop
|
||||
scrollToOffset(Math.max(0, offset - threadEl.value.clientHeight * MENTION_TOP_OFFSET_RATIO))
|
||||
stableFrames = offset === lastOffset ? stableFrames + 1 : 0
|
||||
lastOffset = offset
|
||||
if (stableFrames < MENTION_SETTLE_FRAMES && ++frames < MENTION_MAX_ANCHOR_FRAMES) requestAnimationFrame(anchorToTarget)
|
||||
}
|
||||
}, 50)
|
||||
anchorToTarget()
|
||||
targetEl.classList.add('highlight-mention')
|
||||
setTimeout(() => targetEl.classList.remove('highlight-mention'), HIGHLIGHT_MS)
|
||||
} else {
|
||||
hasUserScrolled.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToMessage = (messageUUID) => {
|
||||
if (!messageUUID) {
|
||||
scrollToBottom()
|
||||
const newMessageHandler = (data) => {
|
||||
if (data.conversation_uuid !== conversationStore.current.uuid) return
|
||||
const message = data.message
|
||||
if (message?.sender_id === userStore.userID) {
|
||||
hasUserScrolled.value = false
|
||||
if (message.type === 'outgoing' && !message.private && !conversationStore.current.assigned_user_id) {
|
||||
showAssignNudge.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const thread = threadEl.value
|
||||
const messageEl = thread?.querySelector(`[data-message-uuid="${messageUUID}"]`)
|
||||
if (messageEl && thread) {
|
||||
// Manual scroll calculation for reliability with variable-height messages
|
||||
const messageTop = messageEl.offsetTop
|
||||
const threadHeight = thread.clientHeight
|
||||
const messageHeight = messageEl.offsetHeight
|
||||
// Position message at ~1/3 from top of viewport for better visibility
|
||||
const targetScroll = messageTop - threadHeight / 3 + messageHeight / 2
|
||||
thread.scrollTop = Math.max(0, targetScroll)
|
||||
|
||||
// Highlight the message briefly
|
||||
messageEl.classList.add('highlight-mention')
|
||||
setTimeout(() => messageEl.classList.remove('highlight-mention'), 2500)
|
||||
} else {
|
||||
// Message not found, scroll to bottom instead
|
||||
scrollToBottom()
|
||||
}
|
||||
}, 150)
|
||||
if (hasUserScrolled.value) unReadMessages.value++
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIfAtBottom()
|
||||
handleNewMessage()
|
||||
})
|
||||
|
||||
const newMessageHandler = (data) => {
|
||||
if (data.conversation_uuid === conversationStore.current.uuid) {
|
||||
// Agent's own message - always scroll to bottom
|
||||
if (data.message?.sender_id === userStore.userID) {
|
||||
scrollToBottom()
|
||||
}
|
||||
// Customer message - only scroll if already at bottom
|
||||
else if (isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
// Customer message but not at bottom - don't scroll, increment unread
|
||||
else {
|
||||
unReadMessages.value++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewMessage = () => {
|
||||
emitter.on(EMITTER_EVENTS.NEW_MESSAGE, newMessageHandler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.NEW_MESSAGE, newMessageHandler)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => conversationStore.conversationMessages,
|
||||
(messages) => {
|
||||
// Scroll to bottom when conversation changes and there are new messages.
|
||||
// New messages on next db page should not scroll to bottom.
|
||||
if (
|
||||
messages.length > 0 &&
|
||||
conversationStore?.current?.uuid &&
|
||||
currentConversationUUID.value !== conversationStore.current.uuid
|
||||
) {
|
||||
currentConversationUUID.value = conversationStore.current.uuid
|
||||
unReadMessages.value = 0
|
||||
|
||||
// Check if this is a mentioned conversation
|
||||
const scrollToUUID = route.query.scrollTo
|
||||
if (scrollToUUID) {
|
||||
// Mentioned conversation - only scroll to message, NOT to bottom
|
||||
scrollToMessage(scrollToUUID)
|
||||
} else {
|
||||
// Normal conversation - scroll to bottom
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
() => conversationStore.current?.uuid,
|
||||
(newUUID) => {
|
||||
if (!newUUID || newUUID === currentConversationUUID) return
|
||||
currentConversationUUID = newUUID
|
||||
unReadMessages.value = 0
|
||||
openScrollDone = false
|
||||
showAssignNudge.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => conversationStore.current?.assigned_user_id,
|
||||
(assignedUserId) => {
|
||||
if (assignedUserId) showAssignNudge.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [conversationStore.conversationMessages.length, conversationStore.messages.loading],
|
||||
([len, loading]) => {
|
||||
if (openScrollDone || loading || !len) return
|
||||
openScrollDone = true
|
||||
hasUserScrolled.value = !!route.query.scrollTo
|
||||
nextTick(applyOpenScroll)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for typing indicator and auto-scroll if user is at bottom
|
||||
watch(
|
||||
() => conversationStore.conversation.isTyping,
|
||||
(isTyping) => {
|
||||
if (isTyping && isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
if (isTyping && !hasUserScrolled.value) nextTick(scrollToBottom)
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for isAtButtom and set unReadMessages to 0
|
||||
watch(
|
||||
() => isAtBottom.value,
|
||||
(atBottom) => {
|
||||
if (atBottom) {
|
||||
unReadMessages.value = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isPrivateNote = (message) => {
|
||||
return message.type === 'outgoing' && message.private
|
||||
}
|
||||
|
||||
const GROUP_WINDOW_MS = 60_000
|
||||
|
||||
const canGroup = (a, b) => {
|
||||
@@ -250,6 +240,20 @@ const getSpacingClass = (index, groupWithPrev) => {
|
||||
return groupWithPrev ? 'mt-1' : 'mt-4'
|
||||
}
|
||||
|
||||
const showLoadMore = computed(
|
||||
() => conversationStore.currentConversationHasMoreMessages && !conversationStore.messages.loading
|
||||
)
|
||||
|
||||
const loadMore = async () => {
|
||||
const thread = threadEl.value
|
||||
if (!thread) return
|
||||
const prevHeight = thread.scrollHeight
|
||||
const prevTop = thread.scrollTop
|
||||
await conversationStore.fetchNextMessages()
|
||||
await nextTick()
|
||||
thread.scrollTop = thread.scrollHeight - prevHeight + prevTop
|
||||
}
|
||||
|
||||
const messageRows = computed(() => {
|
||||
const messages = conversationStore.conversationMessages
|
||||
return messages.map((message, index) => {
|
||||
@@ -259,39 +263,48 @@ const messageRows = computed(() => {
|
||||
message,
|
||||
groupWithPrev,
|
||||
groupWithNext,
|
||||
spacingClass: getSpacingClass(index, groupWithPrev)
|
||||
spacingClass: getSpacingClass(index, groupWithPrev),
|
||||
showDaySeparator:
|
||||
index === 0 ||
|
||||
!isSameDay(new Date(messages[index - 1].created_at), new Date(message.created_at))
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Leaving messages must be out of flow during a conversation swap, else they shift the target's offsetTop mid-scroll. */
|
||||
.message-leaving {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Highlight via an opacity-faded overlay, not the element's own background, to avoid a text repaint flicker when it ends. */
|
||||
.highlight-mention {
|
||||
animation: highlightPulse 2.5s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0% {
|
||||
background-color: rgb(251 191 36 / 0.35);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
.highlight-mention::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgb(251 191 36 / 0.35);
|
||||
pointer-events: none;
|
||||
animation: highlightFade 2.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Dark mode highlight - softer yellow */
|
||||
:global(.dark) .highlight-mention {
|
||||
animation: highlightPulseDark 2.5s ease-out;
|
||||
:global(.dark) .highlight-mention::after {
|
||||
background-color: rgb(250 204 21 / 0.2);
|
||||
}
|
||||
|
||||
@keyframes highlightPulseDark {
|
||||
0% {
|
||||
background-color: rgb(250 204 21 / 0.2);
|
||||
border-radius: 0.5rem;
|
||||
@keyframes highlightFade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="index in count"
|
||||
:key="index"
|
||||
class="flex items-center space-x-4 mb-4"
|
||||
:class="index % 2 === 0 ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<template v-if="index % 2 !== 0">
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="py-2 space-y-5">
|
||||
<div v-for="(row, idx) in rows" :key="idx">
|
||||
<div
|
||||
v-if="row.showName"
|
||||
class="mb-1 flex items-center gap-2"
|
||||
:class="row.outgoing ? 'justify-end pr-[47px]' : 'pl-[47px]'"
|
||||
>
|
||||
<div class="skel-bar h-3 w-24 rounded-sm" />
|
||||
<div class="skel-bar h-3 w-12 rounded-sm" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-4 w-[250px]" />
|
||||
<Skeleton class="h-4 w-[200px]" />
|
||||
<div class="flex flex-row gap-2 w-full" :class="{ 'justify-end': row.outgoing }">
|
||||
<template v-if="!row.outgoing">
|
||||
<div v-if="row.showAvatar" class="skel-bar w-8 h-8 rounded-full flex-shrink-0" />
|
||||
<div v-else class="w-8 flex-shrink-0" />
|
||||
</template>
|
||||
|
||||
<div class="w-4/5" :class="{ 'flex justify-end': row.outgoing }">
|
||||
<div
|
||||
class="skel-bubble flex flex-col gap-2 px-4 pt-3 pb-3 rounded border border-border shadow-sm"
|
||||
:style="{ width: row.width }"
|
||||
>
|
||||
<div
|
||||
v-for="(lineW, n) in row.lines"
|
||||
:key="n"
|
||||
class="skel-bar h-3 rounded-sm"
|
||||
:style="{ width: lineW }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton class="h-12 w-12 rounded-full" />
|
||||
</template>
|
||||
|
||||
<template v-if="row.outgoing">
|
||||
<div v-if="row.showAvatar" class="skel-bar w-8 h-8 rounded-full flex-shrink-0" />
|
||||
<div v-else class="w-8 flex-shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="row.showTime" class="mt-1" :class="row.outgoing ? 'pr-[47px]' : 'pl-[47px]'">
|
||||
<div class="skel-bar h-2.5 w-16 rounded-sm" :class="{ 'ml-auto': row.outgoing }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps({
|
||||
count: Number,
|
||||
const props = defineProps({
|
||||
count: { type: Number, default: 8 },
|
||||
})
|
||||
|
||||
const pattern = [
|
||||
{ outgoing: false, showAvatar: true, showName: true, width: '58%', lines: ['100%', '78%'], showTime: true },
|
||||
{ outgoing: false, showAvatar: false, showName: false, width: '42%', lines: ['100%'], showTime: false },
|
||||
{ outgoing: true, showAvatar: true, showName: true, width: '66%', lines: ['100%', '92%', '60%'], showTime: true },
|
||||
{ outgoing: false, showAvatar: true, showName: true, width: '72%', lines: ['100%', '85%'], showTime: true },
|
||||
{ outgoing: true, showAvatar: true, showName: true, width: '50%', lines: ['100%'], showTime: true },
|
||||
{ outgoing: true, showAvatar: false, showName: false, width: '38%', lines: ['100%'], showTime: false },
|
||||
{ outgoing: false, showAvatar: true, showName: true, width: '60%', lines: ['100%', '70%'], showTime: true },
|
||||
{ outgoing: true, showAvatar: true, showName: true, width: '54%', lines: ['100%', '88%'], showTime: true },
|
||||
]
|
||||
|
||||
const rows = computed(() => {
|
||||
const out = []
|
||||
for (let i = 0; i < props.count; i++) {
|
||||
out.push(pattern[i % pattern.length])
|
||||
}
|
||||
return out
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skel-bar {
|
||||
background-color: hsl(var(--muted));
|
||||
animation: skel-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.skel-bubble {
|
||||
background-color: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
@keyframes skel-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div v-if="conversation.inbox_name">
|
||||
<p class="sidebar-label">{{ $t('globals.terms.inbox', 1) }}</p>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component
|
||||
:is="conversation.inbox_channel === 'livechat' ? MessageSquare : Mail"
|
||||
class="size-3.5 text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<p class="sidebar-value break-all">{{ conversation.inbox_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="conversation.subject">
|
||||
<p class="sidebar-label">{{ $t('globals.terms.subject') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<p v-else class="sidebar-value break-all">
|
||||
<p class="sidebar-value break-all">
|
||||
{{ conversation.subject }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="sidebar-label">{{ $t('globals.terms.referenceNumber') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<p v-else class="sidebar-value">
|
||||
<p class="sidebar-value">
|
||||
{{ conversation.reference_number }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="sidebar-label">{{ $t('globals.terms.initiatedAt') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<p v-if="conversation.created_at" class="sidebar-value">
|
||||
{{ format(conversation.created_at, 'PPpp') }}
|
||||
</p>
|
||||
@@ -34,13 +43,10 @@
|
||||
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<div v-else>
|
||||
<p v-if="conversation.first_reply_at" class="sidebar-value">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else class="sidebar-value">-</p>
|
||||
</div>
|
||||
<p v-if="conversation.first_reply_at" class="sidebar-value">
|
||||
{{ format(conversation.first_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else class="sidebar-value">-</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -53,13 +59,10 @@
|
||||
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<div v-else>
|
||||
<p v-if="conversation.resolved_at" class="sidebar-value">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else class="sidebar-value">-</p>
|
||||
</div>
|
||||
<p v-if="conversation.resolved_at" class="sidebar-value">
|
||||
{{ format(conversation.resolved_at, 'PPpp') }}
|
||||
</p>
|
||||
<p v-else class="sidebar-value">-</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -72,7 +75,6 @@
|
||||
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
|
||||
/>
|
||||
</div>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<p v-if="conversation.last_reply_at" class="sidebar-value">
|
||||
{{ format(conversation.last_reply_at, 'PPpp') }}
|
||||
</p>
|
||||
@@ -81,8 +83,7 @@
|
||||
|
||||
<div v-if="conversation.closed_at">
|
||||
<p class="sidebar-label">{{ $t('globals.terms.closedAt') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4 mt-0.5" />
|
||||
<p v-else class="sidebar-value">
|
||||
<p class="sidebar-value">
|
||||
{{ format(conversation.closed_at, 'PPpp') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -101,20 +102,48 @@
|
||||
:custom-attributes="conversation.custom_attributes || {}"
|
||||
@update:setattributes="updateCustomAttributes"
|
||||
/>
|
||||
|
||||
<div v-if="conversation.csat_responded_at && conversation.csat_rating">
|
||||
<p class="sidebar-label">{{ $t('globals.terms.csatRating') }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg">{{ csatRatingEmoji(conversation.csat_rating) }}</span>
|
||||
<span class="sidebar-value">{{ $t(csatRatingTextKey(conversation.csat_rating)) }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ conversation.csat_rating }}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="conversation.csat_responded_at && conversation.csat_feedback">
|
||||
<p class="sidebar-label">{{ $t('globals.terms.csatFeedback') }}</p>
|
||||
<p
|
||||
class="sidebar-value italic whitespace-pre-wrap"
|
||||
:class="{ 'line-clamp-3': isFeedbackLong && !feedbackExpanded }"
|
||||
>
|
||||
{{ conversation.csat_feedback }}
|
||||
</p>
|
||||
<button
|
||||
v-if="isFeedbackLong"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground mt-1"
|
||||
@click="feedbackExpanded = !feedbackExpanded"
|
||||
>
|
||||
{{ feedbackExpanded ? $t('globals.messages.showLess') : $t('globals.messages.showMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import { Mail, MessageSquare } from 'lucide-vue-next'
|
||||
import SlaBadge from '@/features/sla/SlaBadge.vue'
|
||||
import { useConversationStore } from '../../../stores/conversation'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue'
|
||||
import { useCustomAttributeStore } from '../../../stores/customAttributes'
|
||||
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
|
||||
import { useEmitter } from '../../../composables/useEmitter'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { csatRatingEmoji, csatRatingTextKey } from '@shared-ui/utils/csat.js'
|
||||
import api from '../../../api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -125,6 +154,9 @@ const conversationStore = useConversationStore()
|
||||
const conversation = computed(() => conversationStore.current)
|
||||
customAttributeStore.fetchCustomAttributes()
|
||||
|
||||
const feedbackExpanded = ref(false)
|
||||
const isFeedbackLong = computed(() => (conversation.value?.csat_feedback?.length || 0) > 160)
|
||||
|
||||
const updateCustomAttributes = async (attributes) => {
|
||||
let previousAttributes = conversationStore.current.custom_attributes
|
||||
try {
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
<div>
|
||||
<SelectTag
|
||||
v-if="conversationStore.current"
|
||||
v-model="conversationStore.current.tags"
|
||||
:model-value="conversationStore.current.tags || []"
|
||||
@update:modelValue="onTagsChange"
|
||||
:items="tags.map((tag) => ({ label: tag, value: tag }))"
|
||||
:placeholder="t('placeholders.selectTags')"
|
||||
/>
|
||||
@@ -121,7 +122,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
@@ -158,56 +159,21 @@ const teamsStore = useTeamStore()
|
||||
const tagStore = useTagStore()
|
||||
const userStore = useUserStore()
|
||||
const tags = ref([])
|
||||
// Save the accordion state in local storage
|
||||
const accordionState = useStorage('conversation-sidebar-accordion', [])
|
||||
const { t } = useI18n()
|
||||
let isConversationChange = false
|
||||
customAttributeStore.fetchCustomAttributes()
|
||||
|
||||
// Watch for changes in the current conversation and set the flag
|
||||
watch(
|
||||
() => conversationStore.current,
|
||||
(newConversation, oldConversation) => {
|
||||
// Set the flag when the conversation changes
|
||||
if (newConversation?.uuid !== oldConversation?.uuid) {
|
||||
isConversationChange = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchTags()
|
||||
})
|
||||
|
||||
// Watch for changes in the tags and upsert the tags
|
||||
watch(
|
||||
() => conversationStore.current?.tags,
|
||||
(newTags, oldTags) => {
|
||||
// Skip if the tags change is due to a conversation change.
|
||||
if (isConversationChange) {
|
||||
isConversationChange = false
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if the tags are the same (deep comparison)
|
||||
if (
|
||||
Array.isArray(newTags) &&
|
||||
Array.isArray(oldTags) &&
|
||||
newTags.length === oldTags.length &&
|
||||
newTags.every((item) => oldTags.includes(item))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
conversationStore.updateConversationTags(
|
||||
conversationStore.current.uuid,
|
||||
TAG_ACTION.SET,
|
||||
newTags
|
||||
)
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
const onTagsChange = (newTags) => {
|
||||
const conv = conversationStore.current
|
||||
if (!conv) return
|
||||
const current = conv.tags || []
|
||||
if (newTags.length === current.length && newTags.every((t) => current.includes(t))) return
|
||||
conversationStore.updateConversationTags(conv.uuid, TAG_ACTION.SET, newTags)
|
||||
}
|
||||
|
||||
const priorityOptions = computed(() => conversationStore.priorityOptions)
|
||||
|
||||
|
||||
+7
-20
@@ -28,11 +28,8 @@
|
||||
</div>
|
||||
|
||||
<div class="h-6 flex items-center gap-2">
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-24 h-4" />
|
||||
</span>
|
||||
<router-link
|
||||
v-else-if="userStore.can('contacts:read') && conversation?.contact_id"
|
||||
v-if="userStore.can('contacts:read') && conversation?.contact_id"
|
||||
:to="{ name: 'contact-detail', params: { id: conversation.contact_id } }"
|
||||
class="flex items-center gap-2 hover:underline cursor-pointer"
|
||||
>
|
||||
@@ -45,7 +42,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Mail size="16" class="text-muted-foreground flex-shrink-0" />
|
||||
<Tooltip v-if="isLivechat && !conversationStore.conversation.loading">
|
||||
<Tooltip v-if="isLivechat">
|
||||
<TooltipTrigger as-child>
|
||||
<ShieldCheck v-if="isVerified" size="14" class="flex-shrink-0 text-green-600" />
|
||||
<ShieldQuestion v-else size="14" class="flex-shrink-0 text-amber-500" />
|
||||
@@ -54,10 +51,7 @@
|
||||
isVerified ? t('contact.identityVerified') : t('contact.identityNotVerified')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else-if="conversation?.contact?.email" class="sidebar-value break-all">
|
||||
<span v-if="conversation?.contact?.email" class="sidebar-value break-all">
|
||||
{{ conversation?.contact?.email }}
|
||||
</span>
|
||||
<span v-else class="sidebar-label">
|
||||
@@ -66,19 +60,13 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Phone size="16" class="text-muted-foreground flex-shrink-0" />
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else class="sidebar-value">
|
||||
<span class="sidebar-value">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center" v-if="conversation?.contact?.external_user_id">
|
||||
<IdCard size="16" class="text-muted-foreground flex-shrink-0" />
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else class="sidebar-value">
|
||||
<span class="sidebar-value">
|
||||
{{ conversation.contact.external_user_id }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,7 +86,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Livechat visitor info -->
|
||||
<template v-if="isLivechat && !conversationStore.conversation.loading">
|
||||
<template v-if="isLivechat">
|
||||
<div v-if="conversation?.contact?.country" class="flex gap-2 items-center">
|
||||
<Globe size="16" class="text-muted-foreground flex-shrink-0" />
|
||||
<span class="sidebar-value">{{ countryName }}</span>
|
||||
@@ -114,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Context Links -->
|
||||
<template v-if="contextLinks.length > 0 && !conversationStore.conversation.loading">
|
||||
<template v-if="contextLinks.length > 0">
|
||||
<div
|
||||
v-for="app in contextLinks"
|
||||
:key="app.id"
|
||||
@@ -156,7 +144,6 @@ import { useCountriesStore } from '@/stores/countries'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '../../../api'
|
||||
|
||||
+22
-13
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="pageVisits.length === 0" class="text-center text-sm text-muted-foreground py-4">
|
||||
<div v-if="loaded && pageVisits.length === 0" class="text-center text-sm text-muted-foreground py-4">
|
||||
{{ t('globals.messages.noResultsFound') }}
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
@@ -43,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
|
||||
@@ -53,6 +53,8 @@ import api from '../../../api'
|
||||
const conversationStore = useConversationStore()
|
||||
const conversation = computed(() => conversationStore.current)
|
||||
const { t } = useI18n()
|
||||
const loaded = ref(false)
|
||||
let requestSeq = 0
|
||||
|
||||
const pageVisits = computed(() => {
|
||||
const visits = conversation.value?.contact?.page_visits || []
|
||||
@@ -68,18 +70,25 @@ const pageVisits = computed(() => {
|
||||
watch(
|
||||
() => conversation.value?.uuid,
|
||||
async (uuid) => {
|
||||
if (uuid && conversation.value?.inbox_channel === 'livechat') {
|
||||
try {
|
||||
const resp = await api.getContactPageVisits(uuid)
|
||||
if (resp.data?.data) {
|
||||
conversationStore.mergeContactUpdate({
|
||||
contact_id: conversation.value?.contact_id,
|
||||
page_visits: resp.data.data
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Page visits are optional.
|
||||
const mySeq = ++requestSeq
|
||||
loaded.value = false
|
||||
if (!uuid || conversation.value?.inbox_channel !== 'livechat') {
|
||||
loaded.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await api.getContactPageVisits(uuid)
|
||||
if (mySeq !== requestSeq) return
|
||||
if (resp.data?.data) {
|
||||
conversationStore.mergeContactUpdate({
|
||||
contact_id: conversation.value?.contact_id,
|
||||
page_visits: resp.data.data
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Page visits are optional.
|
||||
} finally {
|
||||
if (mySeq === requestSeq) loaded.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -44,10 +44,8 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="loading" class="w-32 h-4" />
|
||||
|
||||
<!-- Value -->
|
||||
<template v-else-if="attribute.data_type !== 'checkbox'">
|
||||
<template v-if="attribute.data_type !== 'checkbox'">
|
||||
<div
|
||||
v-if="!editingAttributeKey || editingAttributeKey !== attribute.key"
|
||||
class="flex items-center justify-between gap-1"
|
||||
@@ -134,7 +132,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import * as z from 'zod'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { Checkbox } from '@shared-ui/components/ui/checkbox'
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
conversationStore.current?.previous_conversations?.length === 0 ||
|
||||
conversationStore.conversation?.loading
|
||||
!conversationStore.conversation?.loading &&
|
||||
conversationStore.current?.previous_conversations?.length === 0
|
||||
"
|
||||
class="text-center text-sm text-muted-foreground py-4"
|
||||
>
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<router-link
|
||||
v-for="conversation in conversationStore.current.previous_conversations"
|
||||
v-for="conversation in conversationStore.current?.previous_conversations || []"
|
||||
:key="conversation.uuid"
|
||||
:to="{
|
||||
name: 'inbox-conversation',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="flex items-center px-2 h-12">
|
||||
<SidebarTrigger class="cursor-pointer" />
|
||||
<Input
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:placeholder="t('globals.terms.search')"
|
||||
class="w-full border-none shadow-none focus:ring-0 focus:ring-offset-0"
|
||||
@@ -13,6 +14,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { Separator } from '@shared-ui/components/ui/separator'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
|
||||
@@ -23,6 +25,11 @@ const model = defineModel({
|
||||
required: false
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const inputRef = ref(null)
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
inputRef.value?.$el?.focus?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="grid gap-4 py-4">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormField v-slot="{ componentField }" name="name" :validate-on-blur="false">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -33,11 +33,7 @@
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('globals.terms.filter', 2) }}</FormLabel>
|
||||
<FormControl>
|
||||
<FilterBuilder
|
||||
:fields="filterFields"
|
||||
:showButtons="false"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
<FilterGroupBuilder :fields="filterFields" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription> {{ $t('view.form.filters.description') }}</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -55,7 +51,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick, provide } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -75,16 +71,22 @@ import {
|
||||
FormMessage
|
||||
} from '@shared-ui/components/ui/form'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
|
||||
import FilterGroupBuilder from '@main/components/filter/FilterGroupBuilder.vue'
|
||||
import {
|
||||
normalizeToTwoLevel,
|
||||
serializeFilterTree,
|
||||
deserializeFilterTree,
|
||||
collectLeaves,
|
||||
isPartialLeaf,
|
||||
createRoot
|
||||
} from '@main/components/filter/filterTree'
|
||||
import { useConversationFilters } from '../../composables/useConversationFilters'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { EMITTER_EVENTS } from '../../constants/emitterEvents.js'
|
||||
import { useEmitter } from '../../composables/useEmitter'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { OPERATOR } from '../../constants/filterConfig.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { z } from 'zod'
|
||||
import { FIELD_TYPE } from '@/constants/filterConfig'
|
||||
import api from '@/api'
|
||||
|
||||
const emitter = useEmitter()
|
||||
@@ -93,6 +95,8 @@ const nameInputRef = ref(null)
|
||||
const openDialog = defineModel('openDialog', { required: false, default: false })
|
||||
watch(openDialog, (isOpen) => {
|
||||
if (isOpen) {
|
||||
// A cancelled edit leaves the previous view in the form; reset before a fresh create.
|
||||
if (!view.value?.id) form.resetForm()
|
||||
nextTick(() => {
|
||||
nameInputRef.value?.$el?.focus()
|
||||
})
|
||||
@@ -100,11 +104,13 @@ watch(openDialog, (isOpen) => {
|
||||
})
|
||||
const view = defineModel('view', { required: false, default: {} })
|
||||
const isSubmitting = ref(false)
|
||||
const validateTick = ref(0)
|
||||
provide('filterValidateTick', validateTick)
|
||||
const { conversationsListFilters } = useConversationFilters()
|
||||
|
||||
const filterFields = computed(() =>
|
||||
Object.entries(conversationsListFilters.value).map(([field, value]) => ({
|
||||
model: 'conversations',
|
||||
model: value.model || 'conversations',
|
||||
label: value.label,
|
||||
field,
|
||||
type: value.type,
|
||||
@@ -120,78 +126,49 @@ const formSchema = toTypedSchema(
|
||||
required_error: t('globals.messages.required')
|
||||
})
|
||||
.min(2, { message: t('view.form.name.length') })
|
||||
.max(30, { message: t('view.form.name.length') }),
|
||||
.max(140, { message: t('view.form.name.length') }),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
model: z.string().optional(),
|
||||
field: z.string().optional(),
|
||||
operator: z.string().optional(),
|
||||
value: z
|
||||
.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.array(z.union([z.string(), z.number()]))
|
||||
])
|
||||
.optional()
|
||||
})
|
||||
)
|
||||
.default([])
|
||||
.object({
|
||||
logic: z.string().optional(),
|
||||
rules: z.array(z.any()).optional()
|
||||
})
|
||||
.passthrough()
|
||||
.default(() => createRoot())
|
||||
})
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: formSchema
|
||||
validationSchema: formSchema,
|
||||
initialValues: {
|
||||
filters: createRoot()
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
// Make sure at least one filter is selected
|
||||
if (!values.filters || values.filters.length === 0) {
|
||||
const leaves = collectLeaves(values.filters)
|
||||
if (leaves.length === 0) {
|
||||
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
|
||||
return
|
||||
}
|
||||
|
||||
// Check for partial filters
|
||||
const hasPartialFilters = values.filters.some(
|
||||
(f) =>
|
||||
!f.field ||
|
||||
!f.operator ||
|
||||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) &&
|
||||
(!f.value || (Array.isArray(f.value) && f.value.length === 0)))
|
||||
)
|
||||
if (hasPartialFilters) {
|
||||
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
|
||||
if (leaves.some(isPartialLeaf)) {
|
||||
validateTick.value++
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
// Serialize array values to JSON strings for backend
|
||||
if (values.filters) {
|
||||
values.filters = values.filters.map((filter) => {
|
||||
if (Array.isArray(filter.value)) {
|
||||
// Convert string IDs to numbers for backend (tags use string IDs in frontend)
|
||||
const numericValues = filter.value.map((v) => {
|
||||
const num = Number(v)
|
||||
return isNaN(num) ? v : num
|
||||
})
|
||||
return { ...filter, value: JSON.stringify(numericValues) }
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
const payload = { ...values, filters: serializeFilterTree(values.filters) }
|
||||
|
||||
if (values.id) {
|
||||
await api.updateView(values.id, values)
|
||||
if (payload.id) {
|
||||
await api.updateView(payload.id, payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.savedSuccessfully')
|
||||
})
|
||||
} else {
|
||||
await api.createView(values)
|
||||
await api.createView(payload)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.savedSuccessfully')
|
||||
})
|
||||
@@ -209,33 +186,15 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Set form values when view prop changes
|
||||
watch(
|
||||
() => view.value,
|
||||
(newVal) => {
|
||||
if (newVal && Object.keys(newVal).length) {
|
||||
// Deserialize multi-select filter values from JSON strings to arrays
|
||||
const processedVal = { ...newVal }
|
||||
if (processedVal.filters) {
|
||||
processedVal.filters = processedVal.filters.map((filter) => {
|
||||
// Multi-select fields need to be deserialized from JSON strings
|
||||
const field = filterFields.value.find((f) => f.field === filter.field)
|
||||
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
|
||||
|
||||
if (isMultiSelectField && typeof filter.value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filter.value)
|
||||
// Convert numbers back to strings (frontend uses string IDs)
|
||||
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
|
||||
return { ...filter, value: stringValues }
|
||||
} catch (e) {
|
||||
// If parsing fails, return as-is
|
||||
return filter
|
||||
}
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
processedVal.filters = deserializeFilterTree(
|
||||
normalizeToTwoLevel(newVal.filters),
|
||||
filterFields.value
|
||||
)
|
||||
form.setValues(processedVal)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
@layout="onLayoutChange"
|
||||
>
|
||||
<!-- Conversation List Panel -->
|
||||
<ResizablePanel
|
||||
:default-size="panelSizes[0]"
|
||||
:min-size="20"
|
||||
:max-size="45"
|
||||
>
|
||||
<ResizablePanel :default-size="panelSizes[0]" :min-size="20" :max-size="45">
|
||||
<ConversationList />
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -19,7 +15,9 @@
|
||||
<!-- Conversation Detail Panel -->
|
||||
<ResizablePanel :default-size="panelSizes[1]" :min-size="30">
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -36,6 +34,8 @@ import {
|
||||
ResizableHandle
|
||||
} from '@shared-ui/components/ui/resizable'
|
||||
|
||||
defineOptions({ name: 'InboxLayout' })
|
||||
|
||||
const route = useRoute()
|
||||
const isSearchRoute = computed(() => route.name === 'search')
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import AccountLayout from '@main/layouts/account/AccountLayout.vue'
|
||||
import AdminLayout from '@main/layouts/admin/AdminLayout.vue'
|
||||
import { useAppSettingsStore } from '../stores/appSettings'
|
||||
import { getI18n } from '../i18n'
|
||||
import { abortRouteScope } from '../api'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -595,6 +596,11 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Cancel in-flight requests.
|
||||
if (to.fullPath !== from.fullPath) {
|
||||
abortRouteScope()
|
||||
}
|
||||
|
||||
const appSettingsStore = useAppSettingsStore()
|
||||
const siteName = appSettingsStore.settings?.['app.site_name'] || 'libredesk'
|
||||
const i18n = getI18n()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { useEmitter } from '../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../constants/emitterEvents'
|
||||
import api from '../api'
|
||||
|
||||
export const useAiPromptStore = defineStore('aiPrompt', () => {
|
||||
const prompts = ref([])
|
||||
const emitter = useEmitter()
|
||||
let inflight = null
|
||||
let hasFetched = false
|
||||
const fetchPrompts = () => {
|
||||
if (hasFetched) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
inflight = api.getAiPrompts()
|
||||
.then(response => {
|
||||
prompts.value = response?.data?.data || []
|
||||
hasFetched = true
|
||||
})
|
||||
.catch(error => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
return {
|
||||
prompts,
|
||||
fetchPrompts,
|
||||
}
|
||||
})
|
||||
@@ -1,17 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { TYPING_RECEIVE_TIMEOUT } from '@shared-ui/composables/useTypingIndicator.js'
|
||||
import { deepMerge } from '@shared-ui/utils/object.js'
|
||||
import { computeRecipientsFromMessage } from '../utils/email-recipients'
|
||||
import { useEmitter } from '../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../constants/emitterEvents'
|
||||
import { subscribeToConversation, sendTypingIndicator } from '@main/websocket'
|
||||
import { subscribeToConversation, sendTypingIndicator, subscribeListReplace } from '@main/websocket'
|
||||
import { playNotificationSound } from '@shared-ui/composables/useNotificationSound'
|
||||
import MessageCache from '../utils/conversation-message-cache'
|
||||
import { getI18n } from '../i18n'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES, TAG_ACTION } from '@/constants/conversation'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { delayedLoading } from '@/utils/delayed-loading'
|
||||
import api from '../api'
|
||||
|
||||
export const useConversationStore = defineStore('conversation', () => {
|
||||
@@ -24,26 +27,25 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const currentCC = ref([])
|
||||
const macros = ref({})
|
||||
const drafts = ref(new Map())
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const isViewingConversation = (uuid) => router.currentRoute.value.params.uuid === uuid
|
||||
|
||||
// Bulk selection state
|
||||
const selectedUUIDs = ref(new Set())
|
||||
|
||||
// Options for select fields
|
||||
const priorityOptions = computed(() => {
|
||||
return priorities.value.map(p => ({ label: p.name, value: p.id }))
|
||||
})
|
||||
const statusOptions = computed(() => {
|
||||
return statuses.value.map(s => ({ label: s.name, value: s.id }))
|
||||
})
|
||||
// Status options excluding 'Snoozed'
|
||||
const statusOptionsNoSnooze = computed(() =>
|
||||
statuses.value.filter(s => s.name !== 'Snoozed').map(s => ({
|
||||
statuses.value.filter(s => s.name !== CONVERSATION_DEFAULT_STATUSES.SNOOZED).map(s => ({
|
||||
label: s.name,
|
||||
value: s.id
|
||||
}))
|
||||
)
|
||||
|
||||
// Bulk selection methods
|
||||
let lastClickedUUID = null
|
||||
|
||||
const selectedCount = computed(() => selectedUUIDs.value.size)
|
||||
@@ -138,6 +140,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
let typingTimeout = null
|
||||
const typingByUUID = reactive({})
|
||||
const typingTimeoutsByUUID = new Map()
|
||||
|
||||
const conversations = reactive({
|
||||
data: [],
|
||||
@@ -148,6 +152,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
viewID: 0,
|
||||
teamID: 0,
|
||||
loading: false,
|
||||
fetching: false,
|
||||
initialized: false,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
total: 0,
|
||||
@@ -164,16 +170,29 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const messages = reactive({
|
||||
data: new MessageCache(),
|
||||
loading: false,
|
||||
fetching: false,
|
||||
page: 1,
|
||||
// To trigger reactivity on the messages cache, simpler than making MessageCache reactive.
|
||||
version: 0,
|
||||
})
|
||||
|
||||
let seenConversationUUIDs = new Map()
|
||||
// Convos whose message cache is stale; drained lazily by fetchMessages on next open.
|
||||
let staleConversationUUIDs = new Set()
|
||||
const CONVERSATION_CACHE_MAX = 50
|
||||
const conversationDataCache = new Map()
|
||||
|
||||
function cacheConversationData (uuid, data) {
|
||||
if (!uuid || !data) return
|
||||
if (conversationDataCache.has(uuid)) conversationDataCache.delete(uuid)
|
||||
conversationDataCache.set(uuid, data)
|
||||
while (conversationDataCache.size > CONVERSATION_CACHE_MAX) {
|
||||
const oldest = conversationDataCache.keys().next().value
|
||||
if (oldest === conversation.data?.uuid) break
|
||||
conversationDataCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
// Bumped on resetConversations() so in-flight requests can drop stale responses.
|
||||
let contextSeq = 0
|
||||
// Bumped on every fetchConversation() so an older A response can't overwrite a newer B.
|
||||
let openConvSeq = 0
|
||||
const emitter = useEmitter()
|
||||
|
||||
const incrementMessageVersion = () => setTimeout(() => messages.version++, 0)
|
||||
@@ -236,18 +255,32 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function matchesAssignmentScope (conv) {
|
||||
switch (conversations.listType) {
|
||||
case CONVERSATION_LIST_TYPE.ASSIGNED:
|
||||
return conv.assigned_user_id === userStore.userID
|
||||
case CONVERSATION_LIST_TYPE.UNASSIGNED:
|
||||
return !conv.assigned_user_id && !conv.assigned_team_id
|
||||
case CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED:
|
||||
return Number(conv.assigned_team_id) === Number(conversations.teamID) && !conv.assigned_user_id
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToList (conv) {
|
||||
const matched = matchesAssignmentScope(conv)
|
||||
return matched === null ? true : matched
|
||||
}
|
||||
|
||||
const conversationsList = computed(() => {
|
||||
if (!conversations.data) return []
|
||||
let filteredConversations = conversations.data
|
||||
// Filter by status if set.
|
||||
if (conversations.status !== "") {
|
||||
filteredConversations = conversations.data
|
||||
.filter(conv => {
|
||||
return conv.status === conversations.status
|
||||
})
|
||||
filteredConversations = filteredConversations.filter(conv => conv.status === conversations.status)
|
||||
}
|
||||
filteredConversations = filteredConversations.filter(belongsToList)
|
||||
|
||||
// Sort conversations based on the selected sort field
|
||||
return [...filteredConversations].sort((a, b) => {
|
||||
const field = sortFieldMap[conversations.sortField]?.field
|
||||
if (!a[field] && !b[field]) return 0
|
||||
@@ -291,6 +324,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function incrementUnread (uuid) {
|
||||
const row = conversations.data.find(c => c.uuid === uuid)
|
||||
if (!row) return
|
||||
row.unread_message_count = Math.min((row.unread_message_count || 0) + 1, 10)
|
||||
}
|
||||
|
||||
const currentContactName = computed(() => {
|
||||
if (!conversation.data?.contact) return ''
|
||||
return conversation.data?.contact.first_name + ' ' + conversation.data?.contact.last_name
|
||||
@@ -311,7 +350,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return Object.keys(conversation.data || {}).length > 0
|
||||
})
|
||||
|
||||
// Watch for changes in the conversation and messages and update the to, cc, and bcc
|
||||
watchEffect(async () => {
|
||||
const _ = messages.version // eslint-disable-line no-unused-vars
|
||||
const conv = conversation.data
|
||||
@@ -328,11 +366,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
if (!conv || !msgData || !inboxEmail) return
|
||||
|
||||
// Skip automated messages (auto-replies, CSAT) so the reply-box prefill
|
||||
// reflects the last human-driven recipients, not a system-generated one.
|
||||
// Skip automated messages (auto-replies, CSAT) so the prefill reflects the last human-driven recipients.
|
||||
const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true, true)
|
||||
if (!latestMessage) {
|
||||
// Reset recipients if no latest message is found.
|
||||
currentTo.value = []
|
||||
currentCC.value = []
|
||||
currentBCC.value = []
|
||||
@@ -350,57 +386,93 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
currentBCC.value = bcc
|
||||
})
|
||||
|
||||
function resetTypingState () {
|
||||
conversation.isTyping = false
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout)
|
||||
typingTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConversation (uuid) {
|
||||
const seq = ++openConvSeq
|
||||
conversation.loading = true
|
||||
const cached = conversationDataCache.get(uuid)
|
||||
if (cached) {
|
||||
conversation.data = cached
|
||||
resetTypingState()
|
||||
subscribeToConversation(uuid)
|
||||
silentRefetchConversation(uuid)
|
||||
return
|
||||
}
|
||||
const guard = delayedLoading(conversation, 'loading')
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
if (seq !== openConvSeq) return
|
||||
conversation.data = resp.data.data
|
||||
conversation.isTyping = false
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout)
|
||||
typingTimeout = null
|
||||
}
|
||||
// Do a websocket subscription to the conversation.
|
||||
resetTypingState()
|
||||
subscribeToConversation(uuid)
|
||||
cacheConversationData(uuid, conversation.data)
|
||||
} catch (error) {
|
||||
if (seq !== openConvSeq) return
|
||||
conversation.errorMessage = handleHTTPError(error).message
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: conversation.errorMessage
|
||||
})
|
||||
} finally {
|
||||
if (seq === openConvSeq) conversation.loading = false
|
||||
guard.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefetchConversation (uuid) {
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
if (conversation.data?.uuid === uuid) {
|
||||
deepMerge(conversation.data, resp.data.data)
|
||||
cacheConversationData(uuid, conversation.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('silent conversation refetch failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches messages for a conversation if not already present in the cache.
|
||||
*
|
||||
* @param {string} uuid
|
||||
* @returns
|
||||
*/
|
||||
async function fetchMessages (uuid, fetchNextPage = false) {
|
||||
// Messages are already cached?
|
||||
let hasMessages = messages.data.getAllPagesMessages(uuid)
|
||||
if (hasMessages.length > 0 && !fetchNextPage) {
|
||||
if (staleConversationUUIDs.has(uuid) && messages.data.hasConversation(uuid)) {
|
||||
try {
|
||||
const response = await api.getConversationMessages(uuid, { page: 1, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||
const newMessages = response.data?.data?.results || []
|
||||
let lastAdded = null
|
||||
for (const m of newMessages) {
|
||||
if (!messages.data.hasMessage(uuid, m.uuid)) {
|
||||
messages.data.addMessage(uuid, m)
|
||||
lastAdded = m
|
||||
}
|
||||
}
|
||||
staleConversationUUIDs.delete(uuid)
|
||||
if (lastAdded) {
|
||||
incrementMessageVersion()
|
||||
setTimeout(() => {
|
||||
emitter.emit(EMITTER_EVENTS.NEW_MESSAGE, { conversation_uuid: uuid, message: lastAdded })
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchNextPage && messages.data.getAllPagesMessages(uuid).length > 0) {
|
||||
markConversationAsRead(uuid)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch messages from server.
|
||||
messages.loading = true
|
||||
// Increment page number
|
||||
let page = messages.data.getLastFetchedPage(uuid) + 1
|
||||
const guard = fetchNextPage ? null : delayedLoading(messages, 'loading')
|
||||
messages.fetching = true
|
||||
const page = messages.data.getLastFetchedPage(uuid) + 1
|
||||
try {
|
||||
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||
const response = await api.getConversationMessages(uuid, { page, page_size: MESSAGE_LIST_PAGE_SIZE })
|
||||
const result = response.data?.data || {}
|
||||
const newMessages = result.results || []
|
||||
markConversationAsRead(uuid)
|
||||
// Cache messages
|
||||
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
|
||||
messages.data.addMessages(uuid, result.results || [], result.page, result.total_pages)
|
||||
incrementMessageVersion()
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -408,27 +480,20 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
messages.loading = false
|
||||
if (guard) guard.release()
|
||||
messages.fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNextMessages () {
|
||||
fetchMessages(conversation.data.uuid, true)
|
||||
return fetchMessages(conversation.data.uuid, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single message from the server and adds it to the message cache.
|
||||
*
|
||||
* @param {string} conversationUUID
|
||||
* @param {string} messageUUID
|
||||
* @returns {object}
|
||||
*/
|
||||
async function fetchMessage (conversationUUID, messageUUID) {
|
||||
try {
|
||||
const response = await api.getConversationMessage(conversationUUID, messageUUID)
|
||||
if (response?.data?.data) {
|
||||
const newMsg = response.data.data
|
||||
// Add message to cache.
|
||||
messages.data.addMessage(conversationUUID, newMsg)
|
||||
incrementMessageVersion()
|
||||
return newMsg
|
||||
@@ -442,8 +507,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
function fetchNextConversations () {
|
||||
if (conversations.loading || !conversations.hasMore) return
|
||||
fetchConversationsList(true, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID, conversations.page + 1)
|
||||
if (conversations.fetching || !conversations.hasMore) return
|
||||
fetchConversationsList(false, conversations.listType, conversations.teamID, conversations.listFilters, conversations.viewID, conversations.page + 1)
|
||||
}
|
||||
|
||||
function reFetchConversationsList (showLoader = true) {
|
||||
@@ -472,24 +537,31 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
})
|
||||
}
|
||||
conversations.listFilters = filters
|
||||
if (showLoader) conversations.loading = true
|
||||
const guard = showLoader ? delayedLoading(conversations, 'loading') : null
|
||||
conversations.fetching = true
|
||||
if (page === 0) page = conversations.page
|
||||
const seq = contextSeq
|
||||
const isStale = () => seq !== contextSeq
|
||||
try {
|
||||
conversations.errorMessage = ''
|
||||
const response = await makeConversationListRequest(listType, teamID, viewID, filters, page)
|
||||
// Drop response if list context (type/team/view) switched mid-flight.
|
||||
if (seq !== contextSeq) return
|
||||
if (isStale()) return
|
||||
processConversationListResponse(response)
|
||||
} catch (error) {
|
||||
if (seq !== contextSeq) return
|
||||
// Keep existing list visible on error; only surface error on empty list.
|
||||
if (isStale()) return
|
||||
if (conversations.data.length === 0) {
|
||||
conversations.errorMessage = handleHTTPError(error).message
|
||||
conversations.total = 0
|
||||
}
|
||||
} finally {
|
||||
if (seq === contextSeq) conversations.loading = false
|
||||
if (guard) {
|
||||
if (isStale()) guard.cancel()
|
||||
else guard.release()
|
||||
}
|
||||
if (!isStale()) {
|
||||
conversations.initialized = true
|
||||
conversations.fetching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,28 +620,37 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function trimListToCurrentPage () {
|
||||
const maxLen = conversations.page * CONV_LIST_PAGE_SIZE
|
||||
if (conversations.data.length > maxLen) {
|
||||
conversations.data.splice(maxLen)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeIntoList (uuid, payload) {
|
||||
const existing = conversations.data?.find(c => c.uuid === uuid)
|
||||
if (existing) deepMerge(existing, payload)
|
||||
return existing
|
||||
}
|
||||
|
||||
function processConversationListResponse (response) {
|
||||
const apiResponse = response.data.data
|
||||
const newConversations = []
|
||||
for (const conv of apiResponse.results) {
|
||||
if (seenConversationUUIDs.has(conv.uuid)) {
|
||||
// Update existing conversation with fresh data.
|
||||
const idx = conversations.data.findIndex(c => c.uuid === conv.uuid)
|
||||
if (idx !== -1) {
|
||||
deepMerge(conversations.data[idx], conv)
|
||||
}
|
||||
} else {
|
||||
// Add to seen and new conversations list.
|
||||
seenConversationUUIDs.set(conv.uuid, true)
|
||||
newConversations.push(conv)
|
||||
}
|
||||
if (!mergeIntoList(conv.uuid, conv)) newConversations.push(conv)
|
||||
}
|
||||
conversations.page = Math.max(conversations.page, apiResponse.page)
|
||||
conversations.hasMore = apiResponse.total_pages > conversations.page
|
||||
if (!conversations.data) conversations.data = []
|
||||
conversations.data.push(...newConversations)
|
||||
if (apiResponse.page === 1) {
|
||||
conversations.data.unshift(...newConversations)
|
||||
} else {
|
||||
conversations.data.push(...newConversations)
|
||||
}
|
||||
conversations.total = apiResponse.total
|
||||
|
||||
trimListToCurrentPage()
|
||||
|
||||
// Re-check document.hidden in case the user returned while the refresh was in flight.
|
||||
if (pendingNotificationUUIDs.size > 0) {
|
||||
let shouldPlay = false
|
||||
@@ -597,9 +678,13 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
async function updateStatus (v) {
|
||||
if (!conversation.data) return
|
||||
const previous = conversation.data.status
|
||||
conversation.data.status = v
|
||||
try {
|
||||
await api.updateConversationStatus(conversation.data.uuid, { status: v })
|
||||
} catch (error) {
|
||||
if (conversation.data) conversation.data.status = previous
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
@@ -639,10 +724,15 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
async function updateConversationTags (uuid, action, tags) {
|
||||
const source = conversation.data?.uuid === uuid
|
||||
? conversation.data
|
||||
: conversations.data?.find(c => c.uuid === uuid)
|
||||
const previous = source ? [...(source.tags || [])] : null
|
||||
applyTagsLocally(uuid, action, tags)
|
||||
try {
|
||||
await api.upsertTags(uuid, { action, tags })
|
||||
applyTagsLocally(uuid, action, tags)
|
||||
} catch (error) {
|
||||
if (previous) applyTagsLocally(uuid, TAG_ACTION.SET, previous)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
@@ -654,6 +744,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
async function updateAssignee (type, v) {
|
||||
try {
|
||||
await api.updateAssignee(conversation.data.uuid, type, v)
|
||||
conversation.data[`assigned_${type}_id`] = v.assignee_id
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -675,31 +766,25 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
async function updateAssigneeLastSeen (uuid) {
|
||||
if (!isViewingConversation(uuid)) return
|
||||
markConversationAsRead(uuid)
|
||||
try {
|
||||
await api.updateAssigneeLastSeen(uuid)
|
||||
} catch (error) {
|
||||
// pass
|
||||
}
|
||||
api.updateAssigneeLastSeen(uuid).catch(() => { })
|
||||
}
|
||||
|
||||
function isConversationInList (uuid) {
|
||||
return conversations.data?.find(c => c.uuid === uuid) ? true : false
|
||||
return Boolean(conversations.data?.find(c => c.uuid === uuid))
|
||||
}
|
||||
|
||||
// Pending notification UUIDs for new conversations not yet in list (refresh is debounced).
|
||||
// Checked after processConversationListResponse adds conversations to the list.
|
||||
const pendingNotificationUUIDs = new Set()
|
||||
|
||||
function addPendingNotification (uuid) {
|
||||
pendingNotificationUUIDs.add(uuid)
|
||||
}
|
||||
|
||||
const throttledFetchFirstPage = useThrottleFn(fetchFirstPageConversations, 4000)
|
||||
// trailing=true: fires one final refresh after a burst so the list converges to latest state.
|
||||
const throttledFetchFirstPage = useThrottleFn(fetchFirstPageConversations, 60000, true)
|
||||
|
||||
function refreshConversationList () {
|
||||
// Skip while hidden unless we have a pending notification to verify against the new list.
|
||||
if (document.hidden && pendingNotificationUUIDs.size === 0) return
|
||||
throttledFetchFirstPage()
|
||||
}
|
||||
|
||||
@@ -711,28 +796,16 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conv.last_message_sender = message.sender_type
|
||||
}
|
||||
|
||||
/**
|
||||
* Update conversation message in the cache by fetching it from the server.
|
||||
*
|
||||
* @param {object} message - Message object with conversation_uuid field
|
||||
*/
|
||||
async function updateConversationMessage (message) {
|
||||
if (conversation.data?.uuid !== message.conversation_uuid) {
|
||||
// Not the open conversation. If we have cached messages for it,
|
||||
// fetch the new message to keep the cache fresh.
|
||||
if (messages.data.getLastFetchedPage(message.conversation_uuid) > 0) {
|
||||
const fetchedMessage = await fetchMessage(message.conversation_uuid, message.uuid)
|
||||
if (fetchedMessage) {
|
||||
// Update last message in conversation list (preview)
|
||||
updateConversationLastMessage(message.conversation_uuid, fetchedMessage)
|
||||
}
|
||||
// Lazy invalidation: refresh the cache when the user next opens this convo, not on every WS event.
|
||||
if (messages.data.hasConversation(message.conversation_uuid)) {
|
||||
staleConversationUUIDs.add(message.conversation_uuid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Open conversation and message not in cache? Fetch from server.
|
||||
if (!messages.data.hasMessage(message.conversation_uuid, message.uuid)) {
|
||||
// Match echo_id to pending message and swap its UUID so mergeMessageUpdate can find it.
|
||||
const echoId = message.echo_id
|
||||
if (echoId && messages.data.hasMessage(message.conversation_uuid, echoId)) {
|
||||
messages.data.updateMessage(message.conversation_uuid, echoId, { uuid: message.uuid })
|
||||
@@ -741,6 +814,27 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'activity') {
|
||||
const activityMessage = {
|
||||
uuid: message.uuid,
|
||||
conversation_uuid: message.conversation_uuid,
|
||||
type: 'activity',
|
||||
content: message.preview,
|
||||
created_at: message.created_at,
|
||||
updated_at: message.created_at,
|
||||
sender_type: message.sender_type
|
||||
}
|
||||
messages.data.addMessage(message.conversation_uuid, activityMessage)
|
||||
incrementMessageVersion()
|
||||
setTimeout(() => {
|
||||
emitter.emit(EMITTER_EVENTS.NEW_MESSAGE, {
|
||||
conversation_uuid: message.conversation_uuid,
|
||||
message: activityMessage
|
||||
})
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchedMessage = await fetchMessage(message.conversation_uuid, message.uuid)
|
||||
if (fetchedMessage) {
|
||||
updateConversationLastMessage(message.conversation_uuid, fetchedMessage)
|
||||
@@ -830,14 +924,33 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
incrementMessageVersion()
|
||||
}
|
||||
|
||||
function canPushInsert (conv) {
|
||||
const matched = matchesAssignmentScope(conv)
|
||||
if (matched !== null) return matched
|
||||
return conversations.listType === CONVERSATION_LIST_TYPE.ALL
|
||||
}
|
||||
|
||||
function handleConvPush (payload) {
|
||||
if (!payload || !payload.uuid) return
|
||||
if (mergeIntoList(payload.uuid, payload)) {
|
||||
if (conversation.data?.uuid === payload.uuid) {
|
||||
deepMerge(conversation.data, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!canPushInsert(payload)) return
|
||||
if (conversations.status !== '' && payload.status !== conversations.status) return
|
||||
if (!conversations.data) conversations.data = []
|
||||
conversations.data.unshift(payload)
|
||||
conversations.total += 1
|
||||
trimListToCurrentPage()
|
||||
}
|
||||
|
||||
function mergeConversationUpdate (update) {
|
||||
if (conversation.data?.uuid === update.uuid) {
|
||||
deepMerge(conversation.data, update)
|
||||
}
|
||||
const existing = conversations?.data?.find(c => c.uuid === update.uuid)
|
||||
if (existing) {
|
||||
deepMerge(existing, update)
|
||||
}
|
||||
mergeIntoList(update.uuid, update)
|
||||
}
|
||||
|
||||
function mergeContactUpdate (update) {
|
||||
@@ -854,17 +967,17 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Clears the list and pagination state so the next fetch starts fresh (removes stale rows).
|
||||
function resetConversations () {
|
||||
conversations.data = []
|
||||
conversations.page = 1
|
||||
seenConversationUUIDs = new Map()
|
||||
conversations.initialized = false
|
||||
conversations.hasMore = false
|
||||
conversations.total = 0
|
||||
contextSeq++
|
||||
pendingNotificationUUIDs.clear()
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/** Macros set for new conversation or an open conversation **/
|
||||
function setMacro (macro, context) {
|
||||
macros.value[context] = macro
|
||||
}
|
||||
@@ -889,36 +1002,43 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
macros.value = { ...macros.value, [context]: {} }
|
||||
}
|
||||
|
||||
// Typing indicators
|
||||
function updateTypingStatus (typingData) {
|
||||
const { conversation_uuid, is_typing } = typingData
|
||||
const { conversation_uuid: uuid, is_typing } = typingData
|
||||
|
||||
// Only update typing status for the current conversation
|
||||
if (conversation.data?.uuid !== conversation_uuid) return
|
||||
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout)
|
||||
typingTimeout = null
|
||||
if (conversation.data?.uuid === uuid) {
|
||||
if (typingTimeout) {
|
||||
clearTimeout(typingTimeout)
|
||||
typingTimeout = null
|
||||
}
|
||||
conversation.isTyping = is_typing
|
||||
if (is_typing) {
|
||||
typingTimeout = setTimeout(() => {
|
||||
conversation.isTyping = false
|
||||
typingTimeout = null
|
||||
}, TYPING_RECEIVE_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
conversation.isTyping = is_typing
|
||||
|
||||
const prev = typingTimeoutsByUUID.get(uuid)
|
||||
if (prev) clearTimeout(prev)
|
||||
if (is_typing) {
|
||||
typingTimeout = setTimeout(() => {
|
||||
conversation.isTyping = false
|
||||
typingTimeout = null
|
||||
}, TYPING_RECEIVE_TIMEOUT)
|
||||
typingByUUID[uuid] = true
|
||||
typingTimeoutsByUUID.set(uuid, setTimeout(() => {
|
||||
delete typingByUUID[uuid]
|
||||
typingTimeoutsByUUID.delete(uuid)
|
||||
}, TYPING_RECEIVE_TIMEOUT))
|
||||
} else {
|
||||
delete typingByUUID[uuid]
|
||||
typingTimeoutsByUUID.delete(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
function sendTyping (isTyping, otherAttributes = {}) {
|
||||
// Send typing websocket message only if a conversation is open
|
||||
if (conversation.data?.uuid) {
|
||||
sendTypingIndicator(conversation.data.uuid, isTyping, otherAttributes.isPrivateMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all drafts for the current user
|
||||
async function fetchAllDrafts () {
|
||||
try {
|
||||
const resp = await api.getAllDrafts()
|
||||
@@ -937,26 +1057,20 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Get draft for a specific conversation
|
||||
function getDraft (uuid) {
|
||||
return drafts.value.get(uuid)
|
||||
}
|
||||
|
||||
// Set draft for a specific conversation
|
||||
function setDraft (uuid, draft) {
|
||||
drafts.value.set(uuid, draft)
|
||||
// Trigger reactivity
|
||||
drafts.value = new Map(drafts.value)
|
||||
}
|
||||
|
||||
// Remove draft for a specific conversation
|
||||
function removeDraft (uuid) {
|
||||
drafts.value.delete(uuid)
|
||||
// Trigger reactivity
|
||||
drafts.value = new Map(drafts.value)
|
||||
}
|
||||
|
||||
// Check if a conversation has a draft
|
||||
function hasDraft (uuid) {
|
||||
return drafts.value.has(uuid)
|
||||
}
|
||||
@@ -974,6 +1088,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return t('globals.terms.file')
|
||||
}
|
||||
|
||||
// On new conversation uuids, subscribere user to those conversations.
|
||||
watch(
|
||||
() => conversations.data?.map(c => c.uuid).sort().join(',') ?? '',
|
||||
() => subscribeListReplace(conversations.data?.map(c => c.uuid) || [])
|
||||
)
|
||||
|
||||
return {
|
||||
macros,
|
||||
conversations,
|
||||
@@ -991,6 +1111,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
isConversationInList,
|
||||
addPendingNotification,
|
||||
mergeConversationUpdate,
|
||||
handleConvPush,
|
||||
mergeContactUpdate,
|
||||
addNewConversation,
|
||||
getContactFullName,
|
||||
@@ -999,6 +1120,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
mergeMessageUpdate,
|
||||
updateAssigneeLastSeen,
|
||||
markAsUnread,
|
||||
incrementUnread,
|
||||
updateConversationMessage,
|
||||
snoozeConversation,
|
||||
fetchConversation,
|
||||
@@ -1030,6 +1152,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
statusOptionsNoSnooze,
|
||||
statusOptions,
|
||||
updateTypingStatus,
|
||||
typingByUUID,
|
||||
sendTyping,
|
||||
drafts,
|
||||
fetchAllDrafts,
|
||||
|
||||
@@ -26,17 +26,24 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => {
|
||||
...att,
|
||||
}))
|
||||
})
|
||||
const fetchCustomAttributes = async () => {
|
||||
if (attributes.value.length) return
|
||||
try {
|
||||
const response = await api.getCustomAttributes()
|
||||
attributes.value = response?.data?.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
let inflight = null
|
||||
let hasFetched = false
|
||||
const fetchCustomAttributes = () => {
|
||||
if (hasFetched) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
inflight = api.getCustomAttributes()
|
||||
.then(response => {
|
||||
attributes.value = response?.data?.data || []
|
||||
hasFetched = true
|
||||
})
|
||||
}
|
||||
.catch(error => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
return {
|
||||
attributes,
|
||||
|
||||
@@ -81,6 +81,7 @@ export const useMacroStore = defineStore('macroStore', () => {
|
||||
return {
|
||||
macroList,
|
||||
macroOptions,
|
||||
currentView,
|
||||
loadMacros,
|
||||
setCurrentView
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const useUsersStore = defineStore('users', () => {
|
||||
label: user.first_name + ' ' + user.last_name,
|
||||
value: String(user.id),
|
||||
avatar_url: user.avatar_url,
|
||||
availability_status: user.availability_status,
|
||||
})))
|
||||
const fetchUsers = async (force = false) => {
|
||||
if (!force && users.value.length) return
|
||||
@@ -26,9 +27,14 @@ export const useUsersStore = defineStore('users', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
const setAvailability = (agentID, status) => {
|
||||
const u = users.value.find(x => x.id === agentID)
|
||||
if (u) u.availability_status = status
|
||||
}
|
||||
return {
|
||||
users,
|
||||
options,
|
||||
fetchUsers,
|
||||
setAvailability,
|
||||
}
|
||||
})
|
||||
@@ -1,23 +1,13 @@
|
||||
export default class MessageCache {
|
||||
/**
|
||||
* Cache for conversation messages with eviction of old conversations
|
||||
* NOTE- This is not reactive, check implementation in `widget/store/chat.js` to see how this is made reactive.
|
||||
*
|
||||
* @param {number} maxConvs - Max conversations to store before eviction
|
||||
*/
|
||||
constructor(maxConvs = 100) {
|
||||
// Not reactive - see widget/store/chat.js for the reactive wrapper pattern.
|
||||
constructor(maxConvs = 30) {
|
||||
this.cache = new Map()
|
||||
this.maxConvs = maxConvs
|
||||
this.recentConvs = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or updates messages for a conversation page
|
||||
* Updates cache metadata like lastFetchedPage and hasMore
|
||||
*/
|
||||
addMessages (convId, messages, page, totalPages) {
|
||||
const conv = this.cache.get(convId)
|
||||
// Filter out messages already present in cache.
|
||||
const uniqueMsgs = messages.filter(m => !this.hasMessage(convId, m.uuid))
|
||||
|
||||
if (conv) {
|
||||
@@ -36,30 +26,14 @@ export default class MessageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** Purge conversation messages from cache
|
||||
* @param {string} convId - Conversation ID to purge
|
||||
* @returns {boolean} - True if the conversation was purged, false if not found
|
||||
*/
|
||||
purgeConversation (convId) {
|
||||
return this.cache.delete(convId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if message exists in conversation
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMessage (convId, msgId) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return false
|
||||
return Array.from(conv.pages.values()).some(msgs => msgs.some(m => m.uuid === msgId))
|
||||
return this._allMessages(convId).some(m => m.uuid === msgId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds single message to a conversation if not already present
|
||||
*
|
||||
* @param {string} convId - Conversation ID
|
||||
* @param {object} message - Message with uuid field
|
||||
*/
|
||||
addMessage (convId, message) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv || this.hasMessage(convId, message.uuid)) return
|
||||
@@ -70,74 +44,30 @@ export default class MessageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all cached messages for a conversation sorted by creation time
|
||||
*/
|
||||
getAllPagesMessages (convId) {
|
||||
return Array.from(this.cache.get(convId)?.pages.values() || [])
|
||||
.flat()
|
||||
return this._allMessages(convId)
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns latest message for a conversation
|
||||
* @param {string} convId - Conversation ID
|
||||
* @param {string[]} type - Array of message types to filter - outgoing, incoming, etc.
|
||||
* @param {boolean} excludePrivate - Exclude private messages
|
||||
* @param {boolean} excludeAutomated - Exclude automated messages (like CSAT surveys, system messages, etc.)
|
||||
*
|
||||
* @returns {object} - Latest message object or null if not found
|
||||
*/
|
||||
getLatestMessage (convId, type = [], excludePrivate = false, excludeAutomated = false) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return null
|
||||
|
||||
// Get all messages from all pages
|
||||
let allMessages = Array.from(conv.pages.values()).flat()
|
||||
|
||||
// Apply filters
|
||||
if (type.length > 0) {
|
||||
allMessages = allMessages.filter(msg => type.includes(msg.type))
|
||||
}
|
||||
if (excludePrivate) {
|
||||
allMessages = allMessages.filter(msg => !msg.private)
|
||||
}
|
||||
if (excludeAutomated) {
|
||||
allMessages = allMessages.filter(msg => !msg.meta?.is_automated)
|
||||
}
|
||||
|
||||
// Sort messages by created_at in descending order (newest first)
|
||||
allMessages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
|
||||
return allMessages.length ? allMessages[0] : null
|
||||
const filtered = this._allMessages(convId).filter(msg => {
|
||||
if (type.length > 0 && !type.includes(msg.type)) return false
|
||||
if (excludePrivate && msg.private) return false
|
||||
if (excludeAutomated && msg.meta?.is_automated) return false
|
||||
return true
|
||||
})
|
||||
filtered.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
return filtered.length ? filtered[0] : null
|
||||
}
|
||||
/**
|
||||
* Updates message fields by applying update object
|
||||
*/
|
||||
|
||||
updateMessage (convId, msgId, updates) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return
|
||||
conv.pages.forEach(msgs => {
|
||||
const msg = msgs.find(m => m.uuid === msgId)
|
||||
if (msg) Object.assign(msg, updates)
|
||||
})
|
||||
this._updateMessageBy(convId, msgId, msg => Object.assign(msg, updates))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single field in a message
|
||||
*/
|
||||
updateMessageField (convId, msgId, field, value) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return
|
||||
conv.pages.forEach(msgs => {
|
||||
const msg = msgs.find(m => m.uuid === msgId)
|
||||
if (msg) msg[field] = value
|
||||
})
|
||||
this._updateMessageBy(convId, msgId, msg => { msg[field] = value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a message from the cache
|
||||
*/
|
||||
removeMessage (convId, msgId) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return
|
||||
@@ -149,23 +79,14 @@ export default class MessageCache {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if conversation has more pages to fetch
|
||||
*/
|
||||
hasMore (convId) {
|
||||
return this.cache.get(convId)?.hasMore || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns last fetched page number for a conversation
|
||||
*/
|
||||
getLastFetchedPage (convId) {
|
||||
return this.cache.get(convId)?.lastFetchedPage || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* pruneOldConversations - Evicts old conversations from cache
|
||||
*/
|
||||
pruneOldConversations (convId) {
|
||||
this.recentConvs = [convId, ...this.recentConvs.filter(id => id !== convId)]
|
||||
if (this.recentConvs.length > this.maxConvs) {
|
||||
@@ -174,12 +95,22 @@ export default class MessageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a conversation has been added to the cache
|
||||
* @param {string} convId - Conversation ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasConversation (convId) {
|
||||
return this.cache.has(convId)
|
||||
}
|
||||
}
|
||||
|
||||
_allMessages (convId) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return []
|
||||
return Array.from(conv.pages.values()).flat()
|
||||
}
|
||||
|
||||
_updateMessageBy (convId, msgId, mutate) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return
|
||||
conv.pages.forEach(msgs => {
|
||||
const msg = msgs.find(m => m.uuid === msgId)
|
||||
if (msg) mutate(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const pendingHides = new WeakMap()
|
||||
|
||||
export function delayedLoading (target, key, delayMs = 300, minVisibleMs = 500) {
|
||||
const prevHide = pendingHides.get(target)?.get(key)
|
||||
if (prevHide !== undefined) {
|
||||
clearTimeout(prevHide)
|
||||
pendingHides.get(target).delete(key)
|
||||
}
|
||||
|
||||
let triggeredAt = 0
|
||||
let showTimer = null
|
||||
|
||||
if (target[key] === true) {
|
||||
triggeredAt = Date.now()
|
||||
} else {
|
||||
showTimer = setTimeout(() => {
|
||||
triggeredAt = Date.now()
|
||||
target[key] = true
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
return {
|
||||
release () {
|
||||
if (showTimer) clearTimeout(showTimer)
|
||||
if (!triggeredAt) return
|
||||
const remaining = minVisibleMs - (Date.now() - triggeredAt)
|
||||
if (remaining <= 0) {
|
||||
target[key] = false
|
||||
return
|
||||
}
|
||||
const hideTimer = setTimeout(() => {
|
||||
target[key] = false
|
||||
pendingHides.get(target)?.delete(key)
|
||||
}, remaining)
|
||||
let map = pendingHides.get(target)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
pendingHides.set(target, map)
|
||||
}
|
||||
map.set(key, hideTimer)
|
||||
},
|
||||
cancel () {
|
||||
if (showTimer) clearTimeout(showTimer)
|
||||
const pendingHide = pendingHides.get(target)?.get(key)
|
||||
if (pendingHide !== undefined) {
|
||||
clearTimeout(pendingHide)
|
||||
pendingHides.get(target).delete(key)
|
||||
}
|
||||
target[key] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidTemplate } from '@shared-ui/utils/string.js'
|
||||
|
||||
const VARS = ['.Agent.FirstName', '.Agent.LastName', '.Agent.FullName', '.Inbox.Name']
|
||||
|
||||
describe('isValidTemplate', () => {
|
||||
describe('valid', () => {
|
||||
it('empty string', () => {
|
||||
expect(isValidTemplate('', VARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('plain text with no variables', () => {
|
||||
expect(isValidTemplate('Support team', VARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('single supported variable', () => {
|
||||
expect(isValidTemplate('{{ .Agent.FirstName }}', VARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('multiple variables mixed with text', () => {
|
||||
expect(isValidTemplate('{{ .Agent.FirstName }} at {{ .Inbox.Name }}', VARS)).toBe(true)
|
||||
})
|
||||
|
||||
it('variable without surrounding spaces', () => {
|
||||
expect(isValidTemplate('{{.Agent.FullName}}', VARS)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid', () => {
|
||||
it('unknown variable', () => {
|
||||
expect(isValidTemplate('{{ .Agent.Email }}', VARS)).toBe(false)
|
||||
})
|
||||
|
||||
it('typo in variable name', () => {
|
||||
expect(isValidTemplate('{{ .Agent.Naem }}', VARS)).toBe(false)
|
||||
})
|
||||
|
||||
it('missing closing brace', () => {
|
||||
expect(isValidTemplate('{{ .Agent.FirstName }', VARS)).toBe(false)
|
||||
})
|
||||
|
||||
it('extra closing brace', () => {
|
||||
expect(isValidTemplate('{{ .Agent.FirstName }}}', VARS)).toBe(false)
|
||||
})
|
||||
|
||||
it('stray opening braces after a valid token', () => {
|
||||
expect(isValidTemplate('{{ .Inbox.Name }} {{', VARS)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,22 @@ describe('containsQuoteMarkers', () => {
|
||||
expect(containsQuoteMarkers('<div class="OutlookMessageHeader">x</div>')).toBe(true)
|
||||
})
|
||||
|
||||
test('detects Yahoo Mail yahoo_quoted class', () => {
|
||||
const html = `<div id="yahoo_quoted_9284519336" class="yahoo_quoted">
|
||||
<div>On Monday, 18 May 2026, Support wrote:</div>
|
||||
<div>Original message body</div>
|
||||
</div>`
|
||||
expect(containsQuoteMarkers(html)).toBe(true)
|
||||
})
|
||||
|
||||
test('detects Gmail forward gmail_quote_container', () => {
|
||||
const html = `<div class="gmail_quote gmail_quote_container">
|
||||
<div dir="ltr" class="gmail_attr">---------- Forwarded message ---------</div>
|
||||
<div>Original message body</div>
|
||||
</div>`
|
||||
expect(containsQuoteMarkers(html)).toBe(true)
|
||||
})
|
||||
|
||||
test('detects real Hotmail reply payload', () => {
|
||||
const html = `<div class="elementToProof">Quoted reply!</div>
|
||||
<div id="appendonsend"></div>
|
||||
|
||||
@@ -139,6 +139,7 @@ const submitForm = (values) => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
from: values.from,
|
||||
from_name_template: values.from_name_template || '',
|
||||
channel: channelName,
|
||||
enabled: values.enabled ?? true,
|
||||
csat_enabled: values.csat_enabled ?? false,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="conv-progress absolute inset-x-0 top-0 h-0.5 z-50 pointer-events-none"
|
||||
/>
|
||||
<ResizablePanelGroup
|
||||
v-if="showContent"
|
||||
direction="horizontal"
|
||||
class="h-full"
|
||||
class="h-full transition-opacity duration-200"
|
||||
:class="{ 'opacity-60': isDimmed }"
|
||||
:inert="isDimmed"
|
||||
@layout="onLayoutChange"
|
||||
>
|
||||
<!-- Conversation Content Panel -->
|
||||
@@ -44,6 +50,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useStorage, useDocumentVisibility } from '@vueuse/core'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
@@ -58,6 +65,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const route = useRoute()
|
||||
const emitter = useEmitter()
|
||||
const sidebarPanelRef = ref(null)
|
||||
const sidebarOpen = useStorage('conversationSidebarOpen', true)
|
||||
@@ -67,6 +75,12 @@ const showContent = computed(
|
||||
() => conversationStore.current || conversationStore.conversation.loading
|
||||
)
|
||||
|
||||
const isLoading = computed(
|
||||
() => conversationStore.conversation.loading || conversationStore.messages.loading
|
||||
)
|
||||
|
||||
const isDimmed = computed(() => conversationStore.conversation.loading)
|
||||
|
||||
const toggleSidebar = () => {
|
||||
if (sidebarOpen.value) {
|
||||
sidebarPanelRef.value?.collapse()
|
||||
@@ -125,13 +139,33 @@ onMounted(() => {
|
||||
if (props.uuid) fetchConversation(props.uuid)
|
||||
})
|
||||
|
||||
// Watcher for UUID changes
|
||||
watch(
|
||||
() => props.uuid,
|
||||
(newUUID, oldUUID) => {
|
||||
if (newUUID && newUUID !== oldUUID) {
|
||||
if (!newUUID || newUUID === oldUUID) return
|
||||
const canTransition = oldUUID && !route.query.scrollTo && typeof document.startViewTransition === 'function'
|
||||
if (!canTransition) {
|
||||
fetchConversation(newUUID)
|
||||
return
|
||||
}
|
||||
const transition = document.startViewTransition(async () => {
|
||||
fetchConversation(newUUID)
|
||||
await nextTick()
|
||||
})
|
||||
transition.ready.catch(() => {})
|
||||
transition.finished.catch(() => {})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conv-progress {
|
||||
background-color: hsl(var(--primary) / 0.4);
|
||||
animation: conv-progress-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes conv-progress-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<script setup>
|
||||
import { computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useIntervalFn, useDocumentVisibility } from '@vueuse/core'
|
||||
import { useConversationStore } from '../../stores/conversation'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '../../constants/conversation'
|
||||
import ConversationPlaceholder from '@/features/conversation/ConversationPlaceholder.vue'
|
||||
@@ -17,63 +18,61 @@ const viewID = computed(() => route.params.viewID)
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
|
||||
// Init conversations list based on route params
|
||||
onMounted(() => {
|
||||
// Fetch list based on type
|
||||
if (type.value) {
|
||||
// Set list status if not already set
|
||||
if (!conversationStore.getListStatus) {
|
||||
conversationStore.setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN, false)
|
||||
}
|
||||
conversationStore.fetchConversationsList(true, type.value)
|
||||
let lastFetchedKey = ''
|
||||
|
||||
const storeHasCurrentList = () => {
|
||||
const c = conversationStore.conversations
|
||||
if (!c.initialized) return false
|
||||
if (viewID.value) return c.listType === CONVERSATION_LIST_TYPE.VIEW && String(c.viewID) === String(viewID.value)
|
||||
if (type.value) return c.listType === type.value
|
||||
if (teamID.value) return c.listType === CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED && String(c.teamID) === String(teamID.value)
|
||||
return false
|
||||
}
|
||||
|
||||
const fetchForCurrentRoute = () => {
|
||||
if (!type.value && !teamID.value && !viewID.value) return
|
||||
|
||||
const key = `${type.value || ''}|${teamID.value || ''}|${viewID.value || ''}`
|
||||
if (key === lastFetchedKey) return
|
||||
lastFetchedKey = key
|
||||
|
||||
// List already loaded: soft-refresh in place. A full fetch resets the list first and blanks it.
|
||||
if (storeHasCurrentList()) {
|
||||
conversationStore.refreshConversationList()
|
||||
return
|
||||
}
|
||||
// Fetch team list.
|
||||
if (teamID.value) {
|
||||
// Set list status if not already set
|
||||
if (!conversationStore.getListStatus) {
|
||||
conversationStore.setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN, false)
|
||||
}
|
||||
conversationStore.fetchConversationsList(
|
||||
true,
|
||||
CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED,
|
||||
teamID.value
|
||||
)
|
||||
}
|
||||
// Fetch view list.
|
||||
|
||||
if (viewID.value) {
|
||||
// Empty out list status as views are already filtered.
|
||||
conversationStore.setListStatus('', false)
|
||||
conversationStore.fetchConversationsList(true, CONVERSATION_LIST_TYPE.VIEW, 0, [], viewID.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (!conversationStore.getListStatus) {
|
||||
conversationStore.setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN, false)
|
||||
}
|
||||
if (type.value) {
|
||||
conversationStore.fetchConversationsList(true, type.value)
|
||||
} else {
|
||||
conversationStore.fetchConversationsList(true, CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED, teamID.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchForCurrentRoute)
|
||||
|
||||
const visibility = useDocumentVisibility()
|
||||
const { pause, resume } = useIntervalFn(
|
||||
() => conversationStore.refreshConversationList(),
|
||||
120000
|
||||
)
|
||||
watch(visibility, v => {
|
||||
if (v === 'visible') {
|
||||
conversationStore.refreshConversationList()
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
})
|
||||
|
||||
// Refetch when route params change
|
||||
watch(
|
||||
[type, teamID, viewID],
|
||||
([newType, newTeamID, newViewID], [oldType, oldTeamID, oldViewID]) => {
|
||||
if (newType !== oldType && newType) {
|
||||
// Set list status if not already set
|
||||
if (!conversationStore.getListStatus) {
|
||||
conversationStore.setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN, false)
|
||||
}
|
||||
conversationStore.fetchConversationsList(true, newType)
|
||||
}
|
||||
if (newTeamID !== oldTeamID && newTeamID) {
|
||||
// Set list status if not already set
|
||||
if (!conversationStore.getListStatus) {
|
||||
conversationStore.setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN, false)
|
||||
}
|
||||
conversationStore.fetchConversationsList(
|
||||
true,
|
||||
CONVERSATION_LIST_TYPE.TEAM_UNASSIGNED,
|
||||
newTeamID
|
||||
)
|
||||
}
|
||||
if (newViewID !== oldViewID && newViewID) {
|
||||
// Empty out list status as views are already filtered.
|
||||
conversationStore.setListStatus('', false)
|
||||
conversationStore.fetchConversationsList(true, CONVERSATION_LIST_TYPE.VIEW, 0, [], newViewID)
|
||||
}
|
||||
}
|
||||
)
|
||||
watch([type, teamID, viewID], fetchForCurrentRoute)
|
||||
</script>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="flex-1 box p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="card-title">{{ $t('report.csat.cardTitle', { days: csatDays }) }}</p>
|
||||
<DateFilter @filter-change="handleCSATFilterChange" :label="''" />
|
||||
<DateFilter @filter-change="(d) => handleFilterChange('csat', d)" :label="''" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div class="metric-item">
|
||||
@@ -61,7 +61,10 @@
|
||||
<p class="card-title">
|
||||
{{ $t('report.messages.cardTitle', { days: messageVolumeDays }) }}
|
||||
</p>
|
||||
<DateFilter @filter-change="handleMessageVolumeFilterChange" :label="''" />
|
||||
<DateFilter
|
||||
@filter-change="(d) => handleFilterChange('messageVolume', d)"
|
||||
:label="''"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div class="metric-item">
|
||||
@@ -96,7 +99,7 @@
|
||||
<div class="w-full rounded box p-5">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<p class="card-title">{{ slaCardTitle }}</p>
|
||||
<DateFilter @filter-change="handleSlaFilterChange" :label="''" />
|
||||
<DateFilter @filter-change="(d) => handleFilterChange('sla', d)" :label="''" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
@@ -201,7 +204,10 @@
|
||||
<p class="card-title">
|
||||
{{ $t('report.tags.cardTitle', { days: tagDistributionDays }) }}
|
||||
</p>
|
||||
<DateFilter @filter-change="handleTagDistributionFilterChange" :label="''" />
|
||||
<DateFilter
|
||||
@filter-change="(d) => handleFilterChange('tagDistribution', d)"
|
||||
:label="''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -240,7 +246,7 @@
|
||||
<div class="rounded box w-full p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<p class="card-title">{{ $t('report.chart.title') }}</p>
|
||||
<DateFilter @filter-change="handleChartFilterChange" :label="''" />
|
||||
<DateFilter @filter-change="(d) => handleFilterChange('chart', d)" :label="''" />
|
||||
</div>
|
||||
<LineChart :data="processedLineData" />
|
||||
</div>
|
||||
@@ -265,6 +271,7 @@ import api from '../../api'
|
||||
const emitter = useEmitter()
|
||||
const { t } = useI18n()
|
||||
const isLoading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const lastUpdate = ref(new Date())
|
||||
const cardCounts = ref({})
|
||||
const chartData = ref({ status_summary: [] })
|
||||
@@ -292,7 +299,6 @@ const slaCounts = ref({
|
||||
resolution_compliance_percent: 0
|
||||
})
|
||||
|
||||
// New data refs
|
||||
const csatData = ref({
|
||||
average_rating: 0,
|
||||
response_rate: 0,
|
||||
@@ -314,14 +320,53 @@ const tagDistributionData = ref({
|
||||
tagged_percentage: 0
|
||||
})
|
||||
|
||||
// Date filter state
|
||||
const slaDays = ref(30)
|
||||
const chartDays = ref(90)
|
||||
const csatDays = ref(30)
|
||||
const messageVolumeDays = ref(30)
|
||||
const tagDistributionDays = ref(30)
|
||||
const sections = {
|
||||
sla: {
|
||||
days: ref(30),
|
||||
fetch: async (days) => {
|
||||
const { data } = await api.getOverviewSLA({ days })
|
||||
slaCounts.value = { ...slaCounts.value, ...data.data }
|
||||
}
|
||||
},
|
||||
chart: {
|
||||
days: ref(90),
|
||||
fetch: async (days) => {
|
||||
const { data } = await api.getOverviewCharts({ days })
|
||||
chartData.value = {
|
||||
new_conversations: data.data.new_conversations || [],
|
||||
resolved_conversations: data.data.resolved_conversations || [],
|
||||
messages_sent: data.data.messages_sent || []
|
||||
}
|
||||
}
|
||||
},
|
||||
csat: {
|
||||
days: ref(30),
|
||||
fetch: async (days) => {
|
||||
const { data } = await api.getOverviewCSAT({ days })
|
||||
csatData.value = { ...csatData.value, ...data.data }
|
||||
}
|
||||
},
|
||||
messageVolume: {
|
||||
days: ref(30),
|
||||
fetch: async (days) => {
|
||||
const { data } = await api.getOverviewMessageVolume({ days })
|
||||
messageVolumeData.value = { ...messageVolumeData.value, ...data.data }
|
||||
}
|
||||
},
|
||||
tagDistribution: {
|
||||
days: ref(30),
|
||||
fetch: async (days) => {
|
||||
const { data } = await api.getOverviewTagDistribution({ days })
|
||||
tagDistributionData.value = { ...tagDistributionData.value, ...data.data }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slaDays = sections.sla.days
|
||||
const csatDays = sections.csat.days
|
||||
const messageVolumeDays = sections.messageVolume.days
|
||||
const tagDistributionDays = sections.tagDistribution.days
|
||||
|
||||
// Format helpers
|
||||
const formatRating = (value) => {
|
||||
if (!value) return '0.0'
|
||||
return Number(value).toFixed(1)
|
||||
@@ -346,7 +391,6 @@ const formattedSlaCounts = computed(() => ({
|
||||
avg_resolution_time_sec: formatDuration(slaCounts.value.avg_resolution_time_sec, false)
|
||||
}))
|
||||
|
||||
// Dynamic SLA card title based on selected days
|
||||
const slaCardTitle = computed(() => t('report.sla.cardTitle', { days: slaDays.value }))
|
||||
|
||||
const lastUpdateFormatted = computed(() => lastUpdate.value.toLocaleTimeString())
|
||||
@@ -415,105 +459,20 @@ const fetchCardStats = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSLAStats = async (days = slaDays.value) => {
|
||||
const runSectionFetch = async (key, days) => {
|
||||
try {
|
||||
const { data } = await api.getOverviewSLA({ days })
|
||||
slaCounts.value = { ...slaCounts.value, ...data.data }
|
||||
await sections[key].fetch(days)
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChartData = async (days = chartDays.value) => {
|
||||
try {
|
||||
const { data } = await api.getOverviewCharts({ days })
|
||||
chartData.value = {
|
||||
new_conversations: data.data.new_conversations || [],
|
||||
resolved_conversations: data.data.resolved_conversations || [],
|
||||
messages_sent: data.data.messages_sent || []
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCSATStats = async (days = csatDays.value) => {
|
||||
try {
|
||||
const { data } = await api.getOverviewCSAT({ days })
|
||||
csatData.value = { ...csatData.value, ...data.data }
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessageVolumeStats = async (days = messageVolumeDays.value) => {
|
||||
try {
|
||||
const { data } = await api.getOverviewMessageVolume({ days })
|
||||
messageVolumeData.value = { ...messageVolumeData.value, ...data.data }
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTagDistributionStats = async (days = tagDistributionDays.value) => {
|
||||
try {
|
||||
const { data } = await api.getOverviewTagDistribution({ days })
|
||||
tagDistributionData.value = { ...tagDistributionData.value, ...data.data }
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Date filter handlers
|
||||
const handleSlaFilterChange = async (days) => {
|
||||
slaDays.value = days
|
||||
const handleFilterChange = async (key, days) => {
|
||||
if (!initialized.value) return
|
||||
sections[key].days.value = days
|
||||
isLoading.value = true
|
||||
try {
|
||||
await fetchSLAStats(days)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleChartFilterChange = async (days) => {
|
||||
chartDays.value = days
|
||||
isLoading.value = true
|
||||
try {
|
||||
await fetchChartData(days)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCSATFilterChange = async (days) => {
|
||||
csatDays.value = days
|
||||
isLoading.value = true
|
||||
try {
|
||||
await fetchCSATStats(days)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessageVolumeFilterChange = async (days) => {
|
||||
messageVolumeDays.value = days
|
||||
isLoading.value = true
|
||||
try {
|
||||
await fetchMessageVolumeStats(days)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagDistributionFilterChange = async (days) => {
|
||||
tagDistributionDays.value = days
|
||||
isLoading.value = true
|
||||
try {
|
||||
await fetchTagDistributionStats(days)
|
||||
await runSectionFetch(key, days)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
@@ -525,15 +484,12 @@ const loadDashboardData = async () => {
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
fetchCardStats(),
|
||||
fetchSLAStats(),
|
||||
fetchChartData(),
|
||||
fetchCSATStats(),
|
||||
fetchMessageVolumeStats(),
|
||||
fetchTagDistributionStats()
|
||||
...Object.keys(sections).map((key) => runSectionFetch(key, sections[key].days.value))
|
||||
])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
lastUpdate.value = new Date()
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,10 +520,6 @@ onUnmounted(() => {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
@apply text-xs text-muted-foreground uppercase tracking-wider;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useConversationStore } from './stores/conversation'
|
||||
import { useNotificationStore } from './stores/notification'
|
||||
import { useUsersStore } from './stores/users'
|
||||
import { WS_EVENT, WS_EPHEMERAL_TYPES } from './constants/websocket'
|
||||
import { playNotificationSound } from '@shared-ui/composables/useNotificationSound'
|
||||
|
||||
@@ -11,14 +12,15 @@ export class WebSocketClient {
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = 50
|
||||
this.isReconnecting = false
|
||||
this.reconnectTimer = null
|
||||
this.manualClose = false
|
||||
this.pingInterval = null
|
||||
this.lastPong = Date.now()
|
||||
this.convStore = useConversationStore()
|
||||
this.notificationStore = useNotificationStore()
|
||||
this.usersStore = useUsersStore()
|
||||
this.messageQueue = []
|
||||
this.maxQueueSize = 50
|
||||
// 30 sec.
|
||||
this.queueTimeoutMs = 30000
|
||||
}
|
||||
|
||||
@@ -44,13 +46,20 @@ export class WebSocketClient {
|
||||
|
||||
handleOpen () {
|
||||
console.log('WebSocket connected')
|
||||
const wasReconnect = this.reconnectAttempts > 0
|
||||
this.reconnectInterval = 1000
|
||||
this.reconnectAttempts = 0
|
||||
this.isReconnecting = false
|
||||
this.lastPong = Date.now()
|
||||
this.setupPing()
|
||||
// Send any queued messages after connection is established.
|
||||
this.flushMessageQueue()
|
||||
if (wasReconnect) {
|
||||
// RESUB!
|
||||
const uuids = this.convStore.conversations.data?.map(c => c.uuid) || []
|
||||
this.subscribeListReplace(uuids)
|
||||
const openUUID = this.convStore.conversation.data?.uuid
|
||||
if (openUUID) this.subscribeToConversation(openUUID)
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage (event) {
|
||||
@@ -64,29 +73,55 @@ export class WebSocketClient {
|
||||
|
||||
const data = JSON.parse(event.data)
|
||||
const handlers = {
|
||||
// On new message, refresh list and fetch message if it's in current conversation.
|
||||
[WS_EVENT.NEW_MESSAGE]: () => {
|
||||
if (data.data.sender_type === 'contact' && document.hidden) {
|
||||
const uuid = data.data.conversation_uuid
|
||||
if (this.convStore.isConversationInList(uuid)) {
|
||||
const uuid = data.data.conversation_uuid
|
||||
const isOpen = this.convStore.conversation.data?.uuid === uuid
|
||||
const isFromContact = data.data.sender_type === 'contact'
|
||||
const convPayload = data.data.conversation
|
||||
|
||||
if (convPayload) {
|
||||
this.convStore.handleConvPush(convPayload)
|
||||
} else {
|
||||
this.convStore.mergeConversationUpdate({
|
||||
uuid,
|
||||
last_message: data.data.preview,
|
||||
last_message_at: data.data.created_at,
|
||||
last_message_sender: data.data.sender_type,
|
||||
})
|
||||
}
|
||||
|
||||
if (isFromContact && document.hidden) {
|
||||
if (isOpen || this.convStore.isConversationInList(uuid)) {
|
||||
playNotificationSound()
|
||||
} else {
|
||||
this.convStore.addPendingNotification(uuid)
|
||||
}
|
||||
}
|
||||
this.convStore.refreshConversationList()
|
||||
|
||||
if (!isOpen && this.convStore.isConversationInList(uuid)) {
|
||||
this.convStore.incrementUnread(uuid)
|
||||
}
|
||||
|
||||
this.convStore.updateConversationMessage(data.data)
|
||||
},
|
||||
[WS_EVENT.NEW_CONVERSATION]: () => {
|
||||
if (data.data && data.data.uuid) {
|
||||
this.convStore.handleConvPush(data.data)
|
||||
} else {
|
||||
this.convStore.refreshConversationList()
|
||||
}
|
||||
},
|
||||
// Property updates for conversation and message.
|
||||
[WS_EVENT.MESSAGE_UPDATE]: () => this.convStore.mergeMessageUpdate(data.data),
|
||||
[WS_EVENT.CONVERSATION_UPDATE]: () => this.convStore.mergeConversationUpdate(data.data),
|
||||
[WS_EVENT.CONTACT_UPDATE]: () => this.convStore.mergeContactUpdate(data.data),
|
||||
[WS_EVENT.CONVERSATION_SUBSCRIBED]: () => { },
|
||||
[WS_EVENT.TYPING]: () => {
|
||||
this.convStore.updateTypingStatus(data.data)
|
||||
},
|
||||
// New notification.
|
||||
[WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data)
|
||||
[WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data),
|
||||
[WS_EVENT.AGENT_AVAILABILITY_UPDATE]: () =>
|
||||
this.usersStore.setAvailability(data.data.agent_id, data.data.availability_status),
|
||||
}
|
||||
|
||||
const handler = handlers[data.type]
|
||||
@@ -118,8 +153,9 @@ export class WebSocketClient {
|
||||
this.isReconnecting = true
|
||||
this.reconnectAttempts++
|
||||
|
||||
setTimeout(() => {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.isReconnecting = false
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval)
|
||||
}, this.reconnectInterval)
|
||||
@@ -127,10 +163,18 @@ export class WebSocketClient {
|
||||
|
||||
setupNetworkListeners () {
|
||||
window.addEventListener('online', () => {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
this.reconnectInterval = 1000
|
||||
this.reconnect()
|
||||
// Clear any pending reconnect attempts.
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectInterval = 1000
|
||||
this.isReconnecting = false
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
}
|
||||
this.reconnect()
|
||||
})
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
@@ -146,8 +190,8 @@ export class WebSocketClient {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.socket.send('ping')
|
||||
if (Date.now() - this.lastPong > 60000) {
|
||||
console.warn('No pong received in 60 seconds, closing connection')
|
||||
if (Date.now() - this.lastPong > 90000) {
|
||||
console.warn('No pong received in 90 seconds, closing connection')
|
||||
this.socket.close()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -155,7 +199,7 @@ export class WebSocketClient {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
}, 5000)
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
clearPing () {
|
||||
@@ -240,6 +284,10 @@ export class WebSocketClient {
|
||||
this.send(subscribeMessage)
|
||||
}
|
||||
|
||||
subscribeListReplace (uuids) {
|
||||
this.send({ type: WS_EVENT.LIST_SUBSCRIBE_REPLACE, data: { uuids: uuids || [] } })
|
||||
}
|
||||
|
||||
sendTypingIndicator (conversationUUID, isTyping, isPrivateMessage) {
|
||||
if (!conversationUUID) return
|
||||
|
||||
@@ -276,5 +324,6 @@ export function initWS () {
|
||||
|
||||
export const sendMessage = message => wsClient?.send(message)
|
||||
export const subscribeToConversation = conversationUUID => wsClient?.subscribeToConversation(conversationUUID)
|
||||
export const subscribeListReplace = uuids => wsClient?.subscribeListReplace(uuids)
|
||||
export const sendTypingIndicator = (conversationUUID, isTyping, isPrivateMessage) => wsClient?.sendTypingIndicator(conversationUUID, isTyping, isPrivateMessage)
|
||||
export const closeWebSocket = () => wsClient?.close()
|
||||
@@ -7,7 +7,7 @@
|
||||
<title>libredesk Chat Widget</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
|
||||
rel="stylesheet">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: '' },
|
||||
colorClass: { type: String, default: '' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
:class="['px-4 py-2 text-center text-sm', colorClass]"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-current animate-pulse"></div>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,139 +5,143 @@
|
||||
<Spinner size="md" :text="$t('globals.terms.loading')" absolute />
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50"
|
||||
class="flex-1 min-h-0 overflow-y-auto [overflow-anchor:none] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50"
|
||||
ref="messagesContainer"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<!-- Chat Intro -->
|
||||
<ChatIntro v-if="!props.showPreChatForm" :introText="config.chat_introduction" />
|
||||
<div ref="contentEl" class="p-4 flex flex-col gap-4">
|
||||
<!-- Chat Intro -->
|
||||
<ChatIntro v-if="!props.showPreChatForm" :introText="config.chat_introduction" />
|
||||
|
||||
<!-- Notice -->
|
||||
<NoticeBanner
|
||||
v-if="config.notice_banner.enabled === true && !props.showPreChatForm"
|
||||
:noticeText="config.notice_banner.text"
|
||||
/>
|
||||
|
||||
<!-- Messages -->
|
||||
<div
|
||||
v-for="message in chatStore.getCurrentConversationMessages"
|
||||
:key="message.uuid"
|
||||
:class="[
|
||||
'flex flex-col animate-slide-in',
|
||||
message.author.type === 'contact' || message.author.type === 'visitor'
|
||||
? 'items-end'
|
||||
: 'items-start'
|
||||
]"
|
||||
>
|
||||
<!-- CSAT Message Bubble -->
|
||||
<CSATMessageBubble
|
||||
v-if="message.meta?.is_csat"
|
||||
:message="message"
|
||||
@submitted="handleCSATSubmitted"
|
||||
<!-- Notice -->
|
||||
<NoticeBanner
|
||||
v-if="config.notice_banner.enabled === true && !props.showPreChatForm"
|
||||
:noticeText="config.notice_banner.text"
|
||||
/>
|
||||
|
||||
<!-- Regular Message Bubble -->
|
||||
<!-- Messages -->
|
||||
<TransitionGroup tag="div" enter-active-class="animate-slide-in" class="flex flex-col gap-4">
|
||||
<div
|
||||
v-else
|
||||
v-for="message in chatStore.getCurrentConversationMessages"
|
||||
:key="message.uuid"
|
||||
:class="[
|
||||
'max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-5 break-words transition-all duration-200',
|
||||
'flex flex-col',
|
||||
message.author.type === 'contact' || message.author.type === 'visitor'
|
||||
? [
|
||||
'text-primary-foreground rounded-br-sm',
|
||||
message.status === 'sending' || message.status === 'uploading'
|
||||
? 'bg-primary/60'
|
||||
: message.status === 'failed'
|
||||
? 'bg-destructive/60'
|
||||
: 'bg-primary'
|
||||
]
|
||||
: 'bg-muted text-foreground rounded-bl-sm',
|
||||
{
|
||||
'show-quoted-text': isQuotedTextVisible(message.uuid),
|
||||
'hide-quoted-text': !isQuotedTextVisible(message.uuid)
|
||||
}
|
||||
? 'items-end'
|
||||
: 'items-start'
|
||||
]"
|
||||
>
|
||||
<!-- Message content -->
|
||||
<span v-if="message.content_type === 'text'" class="mb-1 whitespace-pre-wrap">{{
|
||||
message.content
|
||||
}}</span>
|
||||
<Letter
|
||||
v-else
|
||||
:html="message.content"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
:allowed-css-properties="extendedCssProperties"
|
||||
class="mb-1 native-html"
|
||||
<!-- CSAT Message Bubble -->
|
||||
<CSATMessageBubble
|
||||
v-if="message.meta?.is_csat"
|
||||
:message="message"
|
||||
@submitted="handleCSATSubmitted"
|
||||
/>
|
||||
|
||||
<!-- Regular Message Bubble -->
|
||||
<div
|
||||
v-if="hasQuotedContent(message.content)"
|
||||
@click="toggleQuote(message.uuid)"
|
||||
role="button"
|
||||
:aria-expanded="isQuotedTextVisible(message.uuid)"
|
||||
v-else
|
||||
:class="[
|
||||
'text-xs cursor-pointer px-2 py-1 w-max rounded transition-all',
|
||||
'max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-5 break-words transition-all duration-200',
|
||||
message.author.type === 'contact' || message.author.type === 'visitor'
|
||||
? 'text-primary-foreground/70 hover:bg-primary-foreground/10 hover:text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-primary'
|
||||
? [
|
||||
'text-primary-foreground rounded-br-sm',
|
||||
message.status === 'sending' || message.status === 'uploading'
|
||||
? 'bg-primary/60'
|
||||
: message.status === 'failed'
|
||||
? 'bg-destructive/60'
|
||||
: 'bg-primary'
|
||||
]
|
||||
: 'bg-muted text-foreground rounded-bl-sm',
|
||||
{
|
||||
'show-quoted-text': isQuotedTextVisible(message.uuid),
|
||||
'hide-quoted-text': !isQuotedTextVisible(message.uuid)
|
||||
}
|
||||
]"
|
||||
>
|
||||
{{
|
||||
isQuotedTextVisible(message.uuid)
|
||||
? t('conversation.hideQuotedText')
|
||||
: t('conversation.showQuotedText')
|
||||
}}
|
||||
</div>
|
||||
<!-- Show attachments if available -->
|
||||
<MessageAttachment :attachments="message.attachments" />
|
||||
</div>
|
||||
|
||||
<!-- Message metadata -->
|
||||
<div class="text-[10px] text-muted-foreground mt-1 flex items-center gap-2">
|
||||
<!-- Agent name and time for agent messages -->
|
||||
<span v-if="message.author.type === 'agent'">
|
||||
{{ message.author.first_name }} {{ message.author.last_name }}
|
||||
•
|
||||
{{ getMessageTime(message.created_at) }}
|
||||
</span>
|
||||
|
||||
<!-- Delivery status for user messages -->
|
||||
<span
|
||||
v-else-if="message.author.type === 'contact' || message.author.type === 'visitor'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
v-if="message.status === 'sending' || message.status === 'uploading'"
|
||||
class="flex items-center gap-1"
|
||||
<!-- Message content -->
|
||||
<span v-if="message.content_type === 'text'" class="mb-1 whitespace-pre-wrap">{{
|
||||
message.content
|
||||
}}</span>
|
||||
<Letter
|
||||
v-else
|
||||
:html="message.content"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
:allowed-css-properties="extendedCssProperties"
|
||||
class="mb-1 native-html"
|
||||
/>
|
||||
<div
|
||||
v-if="containsQuoteMarkers(message.content)"
|
||||
@click="toggleQuote(message.uuid)"
|
||||
role="button"
|
||||
:aria-expanded="isQuotedTextVisible(message.uuid)"
|
||||
:class="[
|
||||
'text-xs cursor-pointer px-2 py-1 w-max rounded transition-all',
|
||||
message.author.type === 'contact' || message.author.type === 'visitor'
|
||||
? 'text-primary-foreground/70 hover:bg-primary-foreground/10 hover:text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-primary'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span v-if="message.status === 'sending'">
|
||||
{{ $t('globals.messages.sending') }}
|
||||
</span>
|
||||
<span v-if="message.status === 'uploading'">
|
||||
{{ $t('globals.messages.uploading') }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
isQuotedTextVisible(message.uuid)
|
||||
? t('conversation.hideQuotedText')
|
||||
: t('conversation.showQuotedText')
|
||||
}}
|
||||
</div>
|
||||
<!-- Show attachments if available -->
|
||||
<MessageAttachment :attachments="message.attachments" />
|
||||
</div>
|
||||
|
||||
<!-- Message metadata -->
|
||||
<div class="text-[10px] text-muted-foreground mt-1 flex items-center gap-2">
|
||||
<!-- Agent name and time for agent messages -->
|
||||
<span v-if="message.author.type === 'agent'">
|
||||
{{ message.author.first_name }} {{ message.author.last_name }}
|
||||
•
|
||||
{{ getMessageTime(message.created_at) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
<div v-if="isTyping" class="flex flex-col items-start">
|
||||
<div
|
||||
class="max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-5 bg-muted text-foreground rounded-bl-sm"
|
||||
>
|
||||
<TypingIndicator />
|
||||
<!-- Delivery status for user messages -->
|
||||
<span
|
||||
v-else-if="message.author.type === 'contact' || message.author.type === 'visitor'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
v-if="message.status === 'sending' || message.status === 'uploading'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span v-if="message.status === 'sending'">
|
||||
{{ $t('globals.messages.sending') }}
|
||||
</span>
|
||||
<span v-if="message.status === 'uploading'">
|
||||
{{ $t('globals.messages.uploading') }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ getMessageTime(message.created_at) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
<div v-if="isTyping" class="flex flex-col items-start">
|
||||
<div
|
||||
class="max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-5 bg-muted text-foreground rounded-bl-sm"
|
||||
>
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky scroll to bottom button -->
|
||||
<ScrollToBottomButton
|
||||
:is-at-bottom="isAtBottom"
|
||||
:is-at-bottom="!hasUserScrolled"
|
||||
:unread-count="unreadMessages"
|
||||
@scroll-to-bottom="handleScrollToBottom"
|
||||
/>
|
||||
@@ -145,7 +149,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useDocumentVisibility, useDebounceFn } from '@vueuse/core'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
@@ -161,6 +165,7 @@ import CSATMessageBubble from './CSATMessageBubble.vue'
|
||||
import { TypingIndicator } from '@shared-ui/components/TypingIndicator'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { containsQuoteMarkers } from '@shared-ui/utils/quotedContent.js'
|
||||
import { useStickyScroll } from '@shared-ui/composables'
|
||||
|
||||
const extendedCssProperties = [...allowedCssProperties, 'transform', 'transform-origin']
|
||||
|
||||
@@ -174,12 +179,16 @@ const props = defineProps({
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const messagesContainer = ref(null)
|
||||
const isAtBottom = ref(true)
|
||||
const contentEl = ref(null)
|
||||
const unreadMessages = ref(0)
|
||||
const currentConversationUUID = ref('')
|
||||
const quotedTextState = ref({})
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasUserScrolled, scrollToBottom, handleScroll } = useStickyScroll(messagesContainer, contentEl, {
|
||||
onArriveBottom: () => { unreadMessages.value = 0 }
|
||||
})
|
||||
|
||||
const config = computed(() => widgetStore.config)
|
||||
const isTyping = computed(() => chatStore.isTyping)
|
||||
const isLoadingConversation = computed(() => chatStore.isLoadingConversation)
|
||||
@@ -188,8 +197,6 @@ const getMessageTime = (timestamp) => {
|
||||
return useRelativeTime(new Date(timestamp)).value
|
||||
}
|
||||
|
||||
const hasQuotedContent = (content) => containsQuoteMarkers(content)
|
||||
|
||||
const isQuotedTextVisible = (messageUuid) => {
|
||||
return quotedTextState.value[messageUuid] || false
|
||||
}
|
||||
@@ -223,34 +230,11 @@ const handleCSATSubmitted = ({ message_uuid, rating, feedback }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const checkIfAtBottom = () => {
|
||||
const container = messagesContainer.value
|
||||
if (container) {
|
||||
const tolerance = 100
|
||||
const isBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <= tolerance
|
||||
isAtBottom.value = isBottom
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
checkIfAtBottom()
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
unreadMessages.value = 0
|
||||
hasUserScrolled.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
checkIfAtBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Debounced version for tab-switch and widget-open triggers only.
|
||||
// New message and conversation switch call the store function directly.
|
||||
const debouncedUpdateLastSeen = useDebounceFn(() => {
|
||||
@@ -267,86 +251,57 @@ watch(visibility, (state) => {
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Check initial scroll position
|
||||
checkIfAtBottom()
|
||||
|
||||
// Scroll to bottom on mount
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
// Auto-scroll for user's own messages or when already at bottom
|
||||
// On any new message addition, update last seen timestamp for this conversation
|
||||
// Conversation switch - reset scroll state and update last seen.
|
||||
watch(
|
||||
() => chatStore.getCurrentConversationMessages,
|
||||
(newMessages, oldMessages) => {
|
||||
if (!newMessages || newMessages.length === 0) return
|
||||
|
||||
// New conversation?
|
||||
const currentConvUUID = chatStore.currentConversation?.uuid
|
||||
if (currentConvUUID && currentConversationUUID.value !== currentConvUUID) {
|
||||
currentConversationUUID.value = currentConvUUID
|
||||
unreadMessages.value = 0
|
||||
scrollToBottom()
|
||||
if (widgetStore.isOpen && !chatStore.isLoadingConversation) {
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip on initial watch run (no prior state) or when widget is collapsed
|
||||
// (no viewport to manage and visitor isn't seeing anything).
|
||||
if (!oldMessages || !widgetStore.isOpen) return
|
||||
|
||||
if (newMessages.length > oldMessages.length) {
|
||||
const newMessage = newMessages[newMessages.length - 1]
|
||||
const isOwnMessage =
|
||||
newMessage.author?.type === 'contact' || newMessage.author?.type === 'visitor'
|
||||
|
||||
// Skip updating last seen if it's own message as backend advances last seen timestamp.
|
||||
if (!isOwnMessage && !document.hidden) {
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
|
||||
// Auto-scroll if:
|
||||
// 1. Message is from current user (contact/visitor), OR
|
||||
// 2. User is already at the bottom
|
||||
if (isOwnMessage || isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
// User is scrolled up and agent sent message - show unread count
|
||||
unreadMessages.value++
|
||||
}
|
||||
} else if (isAtBottom.value) {
|
||||
// Message content changed (e.g. pending replaced with attachment) — keep user at bottom.
|
||||
scrollToBottom()
|
||||
() => chatStore.currentConversation?.uuid,
|
||||
(newUUID) => {
|
||||
if (!newUUID || currentConversationUUID.value === newUUID) return
|
||||
currentConversationUUID.value = newUUID
|
||||
unreadMessages.value = 0
|
||||
hasUserScrolled.value = false
|
||||
nextTick(scrollToBottom)
|
||||
if (widgetStore.isOpen && !chatStore.isLoadingConversation) {
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for typing indicator and auto-scroll if user is at bottom
|
||||
// New message arrival - update last seen for agent messages, increment unread if user scrolled up, force-stick for own messages.
|
||||
watch(
|
||||
() => chatStore.isTyping,
|
||||
(isTyping) => {
|
||||
if (isTyping && isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
() => chatStore.getCurrentConversationMessages.length,
|
||||
(newLen, oldLen) => {
|
||||
if (oldLen === 0 && newLen > 0) {
|
||||
hasUserScrolled.value = false
|
||||
nextTick(scrollToBottom)
|
||||
return
|
||||
}
|
||||
if (!oldLen || !widgetStore.isOpen) return
|
||||
if (newLen <= oldLen) return
|
||||
const messages = chatStore.getCurrentConversationMessages
|
||||
const newMessage = messages[messages.length - 1]
|
||||
const isOwnMessage =
|
||||
newMessage.author?.type === 'contact' || newMessage.author?.type === 'visitor'
|
||||
|
||||
if (!isOwnMessage && !document.hidden) {
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
|
||||
if (isOwnMessage) {
|
||||
hasUserScrolled.value = false
|
||||
} else if (hasUserScrolled.value) {
|
||||
unreadMessages.value++
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for widget becoming visible - scroll to bottom and update last seen
|
||||
// This handles the direct_to_conversation case where messages load while widget is hidden
|
||||
// Widget opening - direct_to_conversation case where messages load while widget is hidden.
|
||||
watch(
|
||||
() => widgetStore.isOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen && chatStore.currentConversation?.uuid) {
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 50)
|
||||
if (!hasUserScrolled.value) scrollToBottom()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300 px-4 py-2 text-center text-sm"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="w-2 h-2 bg-amber-900 dark:bg-amber-300 rounded-full animate-pulse"></div>
|
||||
{{ $t('globals.messages.noInternetConnection') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWidgetStore } from '@widget/store/widget.js'
|
||||
import BaseBanner from './BaseBanner.vue'
|
||||
|
||||
const isOnline = useOnline()
|
||||
const { connectionFailed, connecting, connected } = storeToRefs(useWidgetStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBanner
|
||||
v-if="!isOnline"
|
||||
:text="$t('globals.messages.noInternetConnection')"
|
||||
color-class="bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300"
|
||||
/>
|
||||
<BaseBanner
|
||||
v-else-if="connectionFailed"
|
||||
:text="$t('globals.messages.connectionFailedRefresh')"
|
||||
color-class="bg-destructive text-destructive-foreground"
|
||||
/>
|
||||
<BaseBanner
|
||||
v-else-if="connected"
|
||||
:text="$t('globals.messages.connected')"
|
||||
color-class="bg-green-100 text-green-900 dark:bg-green-950 dark:text-green-300"
|
||||
/>
|
||||
<BaseBanner
|
||||
v-else-if="connecting"
|
||||
:text="$t('globals.messages.connecting')"
|
||||
color-class="bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-300"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,9 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
const isMobileFullScreen = ref(false)
|
||||
const isExpanded = ref(false)
|
||||
const wasExpandedBeforeLeaving = ref(false)
|
||||
const connectionFailed = ref(false)
|
||||
const connecting = ref(false)
|
||||
const connected = ref(false)
|
||||
|
||||
|
||||
// Getters
|
||||
@@ -105,6 +108,18 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
const setConnectionFailed = (failed) => {
|
||||
connectionFailed.value = failed
|
||||
}
|
||||
|
||||
const setConnecting = (value) => {
|
||||
connecting.value = value
|
||||
}
|
||||
|
||||
const setConnected = (value) => {
|
||||
connected.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isOpen,
|
||||
@@ -114,6 +129,9 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
isMobileFullScreen,
|
||||
isExpanded,
|
||||
wasExpandedBeforeLeaving,
|
||||
connectionFailed,
|
||||
connecting,
|
||||
connected,
|
||||
|
||||
// Getters
|
||||
isChatView,
|
||||
@@ -130,5 +148,8 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
expandWidget,
|
||||
collapseWidget,
|
||||
setExpanded,
|
||||
setConnectionFailed,
|
||||
setConnecting,
|
||||
setConnected,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,16 +15,19 @@ export const WS_EVENT = {
|
||||
CONVERSATION_UPDATE: 'conversation_update',
|
||||
}
|
||||
|
||||
// sync retries ~5 min (100 × 3s); WS reconnects every 2s up to ~5 min (150 attempts).
|
||||
const SYNC_MAX_RETRIES = 100
|
||||
const SYNC_RETRY_DELAY_MS = 3000
|
||||
const RECONNECT_INTERVAL_MS = 2000
|
||||
|
||||
let widgetWSClient
|
||||
let _syncOnFirstConnect = true
|
||||
|
||||
export class WidgetWebSocketClient {
|
||||
constructor() {
|
||||
this.socket = null
|
||||
this.reconnectInterval = 1000
|
||||
this.maxReconnectInterval = 30000
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = 50
|
||||
this.maxReconnectAttempts = 150
|
||||
this.isReconnecting = false
|
||||
this.reconnectTimer = null
|
||||
this.manualClose = false
|
||||
@@ -34,6 +37,10 @@ export class WidgetWebSocketClient {
|
||||
this.wsInitiated = false
|
||||
this.token = null
|
||||
this.inboxId = null
|
||||
this.syncRetryTimer = null
|
||||
this.networkListenersSetup = false
|
||||
this.recovering = false
|
||||
this.connectedBannerTimer = null
|
||||
}
|
||||
|
||||
init (token, inboxId) {
|
||||
@@ -60,10 +67,10 @@ export class WidgetWebSocketClient {
|
||||
}
|
||||
|
||||
handleOpen () {
|
||||
this.reconnectInterval = 1000
|
||||
this.reconnectAttempts = 0
|
||||
this.isReconnecting = false
|
||||
this.lastPong = Date.now()
|
||||
useWidgetStore().setConnectionFailed(false)
|
||||
this.setupPing()
|
||||
|
||||
// Auto-join inbox after connection if inbox_id is set.
|
||||
@@ -77,6 +84,8 @@ export class WidgetWebSocketClient {
|
||||
if (this.wsInitiated || _syncOnFirstConnect) {
|
||||
this.syncMissedMessages()
|
||||
}
|
||||
|
||||
this.finishRecovery()
|
||||
this.wsInitiated = true
|
||||
}
|
||||
|
||||
@@ -144,12 +153,21 @@ export class WidgetWebSocketClient {
|
||||
handleClose () {
|
||||
this.clearPing()
|
||||
if (!this.manualClose) {
|
||||
this.beginRecovery()
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
reconnect () {
|
||||
if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return
|
||||
if (this.isReconnecting) return
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
const widgetStore = useWidgetStore()
|
||||
widgetStore.setConnectionFailed(true)
|
||||
widgetStore.setConnecting(false)
|
||||
widgetStore.setConnected(false)
|
||||
this.recovering = false
|
||||
return
|
||||
}
|
||||
|
||||
this.isReconnecting = true
|
||||
this.reconnectAttempts++
|
||||
@@ -158,28 +176,52 @@ export class WidgetWebSocketClient {
|
||||
this.isReconnecting = false
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval)
|
||||
}, this.reconnectInterval)
|
||||
}, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
beginRecovery () {
|
||||
if (!this.wsInitiated || this.recovering) return
|
||||
this.recovering = true
|
||||
clearTimeout(this.connectedBannerTimer)
|
||||
const widgetStore = useWidgetStore()
|
||||
widgetStore.setConnected(false)
|
||||
widgetStore.setConnecting(true)
|
||||
}
|
||||
|
||||
finishRecovery () {
|
||||
if (!this.recovering) return
|
||||
this.recovering = false
|
||||
const widgetStore = useWidgetStore()
|
||||
widgetStore.setConnecting(false)
|
||||
widgetStore.setConnected(true)
|
||||
clearTimeout(this.connectedBannerTimer)
|
||||
this.connectedBannerTimer = setTimeout(() => widgetStore.setConnected(false), 2000)
|
||||
}
|
||||
|
||||
setupNetworkListeners () {
|
||||
if (this.networkListenersSetup) return
|
||||
this.networkListenersSetup = true
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
// Cancel any pending backoff timer and reconnect immediately.
|
||||
if (this.manualClose) return
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.reconnectAttempts = 0
|
||||
this.reconnectInterval = 1000
|
||||
this.isReconnecting = false
|
||||
useWidgetStore().setConnectionFailed(false)
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
}
|
||||
this.beginRecovery()
|
||||
this.syncMissedMessages()
|
||||
this.reconnect()
|
||||
})
|
||||
|
||||
// On tab return, if WS is not connected, sync data immediately and reconnect in parallel.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (this.manualClose) return
|
||||
if (document.visibilityState === 'visible' && this.socket?.readyState !== WebSocket.OPEN) {
|
||||
this.syncMissedMessages()
|
||||
this.reconnect()
|
||||
@@ -189,14 +231,15 @@ export class WidgetWebSocketClient {
|
||||
|
||||
setupPing () {
|
||||
this.clearPing()
|
||||
// Backend read deadline is 20s; ping every 10s so a single missed ping doesn't trip it.
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.socket.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
}))
|
||||
if (Date.now() - this.lastPong > 60000) {
|
||||
console.warn('No pong received in 60 seconds, closing widget connection')
|
||||
if (Date.now() - this.lastPong > 30000) {
|
||||
console.warn('No pong received in 30 seconds, closing widget connection')
|
||||
this.socket.close()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -204,7 +247,7 @@ export class WidgetWebSocketClient {
|
||||
this.reconnect()
|
||||
}
|
||||
}
|
||||
}, 5000)
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
clearPing () {
|
||||
@@ -231,17 +274,22 @@ export class WidgetWebSocketClient {
|
||||
this.send(joinMessage)
|
||||
}
|
||||
|
||||
// Silently refresh conversation list and current conversation to catch messages missed while WS was disconnected.
|
||||
syncMissedMessages () {
|
||||
async syncMissedMessages (attempt = 0) {
|
||||
const now = Date.now()
|
||||
if (now - this.lastSyncAt < 2000) return
|
||||
if (attempt === 0 && now - this.lastSyncAt < 2000) return
|
||||
this.lastSyncAt = now
|
||||
clearTimeout(this.syncRetryTimer)
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.fetchConversations(true, true)
|
||||
const conversationsOk = await chatStore.fetchConversations(true, true)
|
||||
const currentConversationUUID = chatStore.currentConversation?.uuid
|
||||
let conversationOk = true
|
||||
if (currentConversationUUID) {
|
||||
chatStore.loadConversation(currentConversationUUID, true, true)
|
||||
conversationOk = await chatStore.loadConversation(currentConversationUUID, true, true)
|
||||
}
|
||||
|
||||
if ((!conversationsOk || !conversationOk) && attempt < SYNC_MAX_RETRIES) {
|
||||
this.syncRetryTimer = setTimeout(() => this.syncMissedMessages(attempt + 1), SYNC_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +315,20 @@ export class WidgetWebSocketClient {
|
||||
close () {
|
||||
this.manualClose = true
|
||||
this.clearPing()
|
||||
clearTimeout(this.syncRetryTimer)
|
||||
this.syncRetryTimer = null
|
||||
clearTimeout(this.connectedBannerTimer)
|
||||
this.connectedBannerTimer = null
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
this.isReconnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
this.recovering = false
|
||||
this.lastSyncAt = 0
|
||||
const widgetStore = useWidgetStore()
|
||||
widgetStore.setConnectionFailed(false)
|
||||
widgetStore.setConnecting(false)
|
||||
widgetStore.setConnected(false)
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// End-to-end journey against a real backend (no API mocking):
|
||||
// login -> create email inbox -> create team -> create agent ->
|
||||
// create an outgoing conversation -> reply (sent through MailHog).
|
||||
//
|
||||
// The steps run in order and share state, so the conversation reuses the
|
||||
// inbox created earlier in the same spec.
|
||||
|
||||
describe('Admin setup and outgoing conversation', () => {
|
||||
const stamp = Date.now()
|
||||
const inboxName = `Cypress Inbox ${stamp}`
|
||||
const fromAddress = `Cypress Support <support+${stamp}@cypress.test>`
|
||||
const teamName = `Cypress Team ${stamp}`
|
||||
const agentFirstName = 'Cypress'
|
||||
const agentEmail = `cypress.agent.${stamp}@example.com`
|
||||
const contactEmail = `cypress.customer.${stamp}@example.com`
|
||||
const subject = `Cypress subject ${stamp}`
|
||||
const replyBody = `Automated reply from Cypress ${stamp}`
|
||||
|
||||
// SMTP points at the MailHog sink running as a CI service; IMAP is dummy.
|
||||
const smtpHost = Cypress.env('SMTP_HOST') || '127.0.0.1'
|
||||
const smtpPort = Cypress.env('SMTP_PORT') || '1025'
|
||||
const mailhogUrl = Cypress.env('MAILHOG_URL')
|
||||
|
||||
let conversationUuid
|
||||
|
||||
beforeEach(() => {
|
||||
cy.viewport(1280, 800) // desktop layout so the inbox sidebar (New conversation) is visible
|
||||
cy.login()
|
||||
})
|
||||
|
||||
it('creates an email inbox that sends through MailHog', () => {
|
||||
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
|
||||
|
||||
cy.visit('/admin/inboxes/new')
|
||||
cy.contains('Create an email inbox').click()
|
||||
cy.contains('Configure IMAP and SMTP manually').click()
|
||||
|
||||
cy.get('input[name="name"]').type(inboxName)
|
||||
cy.get('input[name="from"]').type(fromAddress)
|
||||
|
||||
cy.get('input[name="imap.username"]').type('cypress')
|
||||
cy.get('input[name="imap.password"]').type('cypress')
|
||||
|
||||
// smtp.host/port also exist in the hidden OAuth section, so target the visible ones.
|
||||
cy.get('input[name="smtp.host"]:visible').clear().type(smtpHost)
|
||||
cy.get('input[name="smtp.port"]:visible').clear().type(smtpPort)
|
||||
cy.get('input[name="smtp.username"]').type('cypress')
|
||||
cy.get('input[name="smtp.password"]').type('cypress')
|
||||
cy.selectOption('Login', 'None') // SMTP auth protocol -> None (MailHog needs none)
|
||||
|
||||
cy.contains('button', 'Create').click()
|
||||
cy.wait('@createInbox').its('response.statusCode').should('eq', 200)
|
||||
cy.location('pathname').should('eq', '/admin/inboxes')
|
||||
})
|
||||
|
||||
it('creates a team', () => {
|
||||
cy.intercept('POST', '**/api/v1/teams').as('createTeam')
|
||||
|
||||
cy.visit('/admin/teams/teams/new')
|
||||
|
||||
// Emoji is required. Pick it first so the picker overlay is closed (by the
|
||||
// next click) before filling the rest of the form.
|
||||
cy.get('input[name="emoji"]').click()
|
||||
cy.get('.v3-emoji-picker .v3-emojis button').first().click()
|
||||
|
||||
cy.get('input[name="name"]').type(teamName)
|
||||
cy.selectOption('Select an assignment type', 'Round robin')
|
||||
cy.contains('button[role="combobox"]', 'Select timezone').click()
|
||||
cy.get('[role="option"]').first().click()
|
||||
|
||||
cy.contains('button', 'Create').click()
|
||||
cy.wait('@createTeam').its('response.statusCode').should('eq', 200)
|
||||
cy.location('pathname').should('eq', '/admin/teams/teams')
|
||||
})
|
||||
|
||||
it('creates an agent', () => {
|
||||
cy.intercept('POST', '**/api/v1/agents').as('createAgent')
|
||||
|
||||
cy.visit('/admin/teams/agents/new')
|
||||
|
||||
cy.get('input[name="first_name"]').type(agentFirstName)
|
||||
cy.get('input[name="email"]').type(agentEmail)
|
||||
|
||||
cy.get('input[placeholder="Select roles"]').click()
|
||||
cy.get('[role="option"]').contains('Agent').click()
|
||||
|
||||
cy.contains('button', 'Create').click()
|
||||
cy.wait('@createAgent').its('response.statusCode').should('eq', 200)
|
||||
cy.location('pathname').should('eq', '/admin/teams/agents')
|
||||
})
|
||||
|
||||
it('creates an outgoing conversation from the new inbox', () => {
|
||||
cy.intercept('POST', '**/api/v1/conversations').as('createConversation')
|
||||
|
||||
cy.visit('/inboxes/assigned')
|
||||
cy.contains('New conversation').click()
|
||||
|
||||
cy.get('[role="dialog"]').within(() => {
|
||||
cy.get('input[type="email"]').type(contactEmail)
|
||||
cy.get('input[name="first_name"]').type('Cypress')
|
||||
cy.get('input[name="last_name"]').type('Customer')
|
||||
cy.get('input[name="subject"]').type(subject)
|
||||
cy.contains('button[role="combobox"]', 'Select inbox').click()
|
||||
})
|
||||
// Radix Select options render in a portal outside the dialog.
|
||||
cy.get('[role="option"]').contains(inboxName).click()
|
||||
|
||||
cy.get('[role="dialog"]')
|
||||
.find('.tiptap.ProseMirror')
|
||||
.click()
|
||||
.type('Hello, this is an automated outgoing conversation.')
|
||||
cy.get('[role="dialog"]').contains('button', 'Submit').click()
|
||||
|
||||
cy.wait('@createConversation').then(({ response }) => {
|
||||
expect(response.statusCode).to.eq(200)
|
||||
conversationUuid = response.body.data.uuid
|
||||
expect(conversationUuid, 'conversation uuid').to.be.a('string').and.not.be.empty
|
||||
})
|
||||
})
|
||||
|
||||
it('replies to the conversation and the email lands in MailHog', () => {
|
||||
expect(conversationUuid, 'conversation from previous step').to.be.a('string')
|
||||
|
||||
cy.intercept('POST', `**/api/v1/conversations/${conversationUuid}/messages`).as('sendReply')
|
||||
|
||||
cy.visit(`/inboxes/all/conversation/${conversationUuid}`)
|
||||
cy.get('.tiptap.ProseMirror').first().click().type(replyBody)
|
||||
cy.contains('button', /^Send$/).click() // exact: avoid the adjacent "Send and set status" split button
|
||||
|
||||
cy.wait('@sendReply').its('response.statusCode').should('eq', 200)
|
||||
cy.contains(replyBody).should('exist')
|
||||
|
||||
// Outgoing mail is dispatched asynchronously; poll MailHog until it shows up.
|
||||
// Only runs in CI where MAILHOG_URL is set.
|
||||
if (mailhogUrl) {
|
||||
const findInMailHog = (attempt = 0) => {
|
||||
cy.request(`${mailhogUrl}/api/v2/messages`).then((res) => {
|
||||
const delivered = (res.body.items || []).some((m) =>
|
||||
JSON.stringify(m).includes(contactEmail)
|
||||
)
|
||||
if (delivered) return
|
||||
if (attempt >= 15) {
|
||||
throw new Error(`Email to ${contactEmail} not found in MailHog`)
|
||||
}
|
||||
cy.wait(1000)
|
||||
findInMailHog(attempt + 1)
|
||||
})
|
||||
}
|
||||
findInMailHog()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -23,3 +23,32 @@
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
// Real login as the System user. Caches the session (and the csrf_token cookie
|
||||
// the backend sets on login) across specs so subsequent writes don't 403.
|
||||
Cypress.Commands.add('login', () => {
|
||||
const email = 'System'
|
||||
const password = Cypress.env('SYSTEM_PASSWORD') || 'StrongPass!123'
|
||||
cy.session(
|
||||
'system-agent',
|
||||
() => {
|
||||
cy.visit('/')
|
||||
cy.get('#email').clear().type(email)
|
||||
cy.get('#password').clear().type(password, { log: false })
|
||||
cy.contains('button', 'Sign in').click()
|
||||
cy.url().should('include', '/inboxes')
|
||||
},
|
||||
{
|
||||
cacheAcrossSpecs: true,
|
||||
validate() {
|
||||
cy.request('/api/v1/agents/me').its('status').should('eq', 200)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Pick an option from a shadcn/radix Select by the label currently on its trigger.
|
||||
Cypress.Commands.add('selectOption', (triggerLabel, optionText) => {
|
||||
cy.contains('button[role="combobox"]', triggerLabel).click()
|
||||
cy.get('[role="option"]').contains(optionText).click()
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@unovis/vue": "^1.6.5",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^12.4.0",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
@@ -87,7 +87,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.2.2"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||
}
|
||||
Generated
+164
-246
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^12.4.0
|
||||
version: 12.4.0(typescript@5.7.3)
|
||||
axios:
|
||||
specifier: ^1.15.2
|
||||
version: 1.15.2(debug@4.4.0)
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0(debug@4.4.0)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -161,7 +161,7 @@ importers:
|
||||
version: 1.10.5
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))(vue@3.5.13(typescript@5.7.3))
|
||||
version: 5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))(vue@3.5.13(typescript@5.7.3))
|
||||
'@vue/eslint-config-prettier':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
|
||||
@@ -203,10 +203,10 @@ importers:
|
||||
version: 1.0.7(tailwindcss@3.4.17)
|
||||
vite:
|
||||
specifier: ^6.4.2
|
||||
version: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
version: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(@types/node@25.8.0)(jiti@1.21.7)(jsdom@29.0.1)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -669,9 +669,6 @@ packages:
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -982,6 +979,9 @@ packages:
|
||||
'@sideway/pinpoint@2.0.0':
|
||||
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -1192,8 +1192,8 @@ packages:
|
||||
'@tiptap/pm': ^2.7.0
|
||||
vue: ^3.0.0
|
||||
|
||||
'@types/chai@5.2.2':
|
||||
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
@@ -1309,6 +1309,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
@@ -1412,34 +1415,34 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/expect@3.2.2':
|
||||
resolution: {integrity: sha512-ipHw0z669vEMjzz3xQE8nJX1s0rQIb7oEl4jjl35qWTwm/KIHERIg/p/zORrjAaZKXfsv7IybcNGHwhOOAPMwQ==}
|
||||
'@vitest/expect@4.1.0':
|
||||
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
|
||||
|
||||
'@vitest/mocker@3.2.2':
|
||||
resolution: {integrity: sha512-jKojcaRyIYpDEf+s7/dD3LJt53c0dPfp5zCPXz9H/kcGrSlovU/t1yEaNzM9oFME3dcd4ULwRI/x0Po1Zf+LTw==}
|
||||
'@vitest/mocker@4.1.0':
|
||||
resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@3.2.2':
|
||||
resolution: {integrity: sha512-FY4o4U1UDhO9KMd2Wee5vumwcaHw7Vg4V7yR4Oq6uK34nhEJOmdRYrk3ClburPRUA09lXD/oXWZ8y/Sdma0aUQ==}
|
||||
'@vitest/pretty-format@4.1.0':
|
||||
resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
|
||||
|
||||
'@vitest/runner@3.2.2':
|
||||
resolution: {integrity: sha512-GYcHcaS3ejGRZYed2GAkvsjBeXIEerDKdX3orQrBJqLRiea4NSS9qvn9Nxmuy1IwIB+EjFOaxXnX79l8HFaBwg==}
|
||||
'@vitest/runner@4.1.0':
|
||||
resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
|
||||
|
||||
'@vitest/snapshot@3.2.2':
|
||||
resolution: {integrity: sha512-aMEI2XFlR1aNECbBs5C5IZopfi5Lb8QJZGGpzS8ZUHML5La5wCbrbhLOVSME68qwpT05ROEEOAZPRXFpxZV2wA==}
|
||||
'@vitest/snapshot@4.1.0':
|
||||
resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
|
||||
|
||||
'@vitest/spy@3.2.2':
|
||||
resolution: {integrity: sha512-6Utxlx3o7pcTxvp0u8kUiXtRFScMrUg28KjB3R2hon7w4YqOFAEA9QwzPVVS1QNL3smo4xRNOpNZClRVfpMcYg==}
|
||||
'@vitest/spy@4.1.0':
|
||||
resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
|
||||
|
||||
'@vitest/utils@3.2.2':
|
||||
resolution: {integrity: sha512-qJYMllrWpF/OYfWHP32T31QCaLa3BAzT/n/8mNGhPdVcjY+JYazQFO1nsJvXU12Kp1xMpNY4AGuljPTNjQve6A==}
|
||||
'@vitest/utils@4.1.0':
|
||||
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
|
||||
@@ -1623,8 +1626,8 @@ packages:
|
||||
aws4@1.13.2:
|
||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
||||
|
||||
axios@1.15.2:
|
||||
resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||
@@ -1679,10 +1682,6 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
cachedir@2.4.0:
|
||||
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1709,18 +1708,14 @@ packages:
|
||||
caseless@0.12.0:
|
||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||
|
||||
chai@5.2.0:
|
||||
resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
|
||||
engines: {node: '>=12'}
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
check-error@2.1.1:
|
||||
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
check-more-types@2.24.0:
|
||||
resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1802,6 +1797,9 @@ packages:
|
||||
convert-source-map@1.9.0:
|
||||
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
@@ -2030,15 +2028,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -2055,10 +2044,6 @@ packages:
|
||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -2143,11 +2128,11 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
@@ -2259,8 +2244,8 @@ packages:
|
||||
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
expect-type@1.2.1:
|
||||
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
extend@3.0.2:
|
||||
@@ -2457,8 +2442,8 @@ packages:
|
||||
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hasown@2.0.3:
|
||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hookable@5.5.3:
|
||||
@@ -2724,9 +2709,6 @@ packages:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
loupe@3.1.3:
|
||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
@@ -2740,8 +2722,8 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
map-stream@0.1.0:
|
||||
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
||||
@@ -2867,6 +2849,9 @@ packages:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
@@ -2939,10 +2924,6 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pathval@2.0.0:
|
||||
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
pause-stream@0.0.11:
|
||||
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
|
||||
|
||||
@@ -2966,10 +2947,6 @@ packages:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
picomatch@4.0.2:
|
||||
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
picomatch@4.0.4:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -3347,8 +3324,8 @@ packages:
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
std-env@3.9.0:
|
||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stream-combiner@0.0.4:
|
||||
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
|
||||
@@ -3478,30 +3455,23 @@ packages:
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@1.1.0:
|
||||
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyqueue@2.0.3:
|
||||
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
|
||||
|
||||
tinyrainbow@2.0.0:
|
||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyspy@4.0.3:
|
||||
resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tippy.js@6.3.7:
|
||||
@@ -3629,11 +3599,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
|
||||
vite-node@3.2.2:
|
||||
resolution: {integrity: sha512-Xj/jovjZvDXOq2FgLXu8NsY4uHUMWtzVmMC2LkCu9HWdr9Qu1Is5sanX3Z4jOFKdohfaWDnEJWp9pRP0vVpAcA==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@6.4.2:
|
||||
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -3674,26 +3639,33 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@3.2.2:
|
||||
resolution: {integrity: sha512-fyNn/Rp016Bt5qvY0OQvIUCwW2vnaEBLxP42PmKbNIoasSYjML+8xyeADOPvBe+Xfl/ubIw4og7Lt9jflRsCNw==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
vitest@4.1.0:
|
||||
resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@types/debug': ^4.1.12
|
||||
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||
'@vitest/browser': 3.2.2
|
||||
'@vitest/ui': 3.2.2
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.0
|
||||
'@vitest/browser-preview': 4.1.0
|
||||
'@vitest/browser-webdriverio': 4.1.0
|
||||
'@vitest/ui': 4.1.0
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@types/debug':
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser':
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
@@ -3840,9 +3812,9 @@ packages:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yaml@2.7.1:
|
||||
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
|
||||
engines: {node: '>= 14'}
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yauzl@2.10.0:
|
||||
@@ -4346,21 +4318,19 @@ snapshots:
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
@@ -4591,6 +4561,8 @@ snapshots:
|
||||
|
||||
'@sideway/pinpoint@2.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -4811,9 +4783,10 @@ snapshots:
|
||||
'@tiptap/pm': 2.11.2
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@types/chai@5.2.2':
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
@@ -4950,6 +4923,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/leaflet@1.7.6':
|
||||
@@ -5129,51 +5104,51 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))(vue@3.5.13(typescript@5.7.3))':
|
||||
'@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@vitest/expect@3.2.2':
|
||||
'@vitest/expect@4.1.0':
|
||||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/spy': 3.2.2
|
||||
'@vitest/utils': 3.2.2
|
||||
chai: 5.2.0
|
||||
tinyrainbow: 2.0.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.0
|
||||
'@vitest/utils': 4.1.0
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@3.2.2(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))':
|
||||
'@vitest/mocker@4.1.0(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.2
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@3.2.2':
|
||||
'@vitest/pretty-format@4.1.0':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@3.2.2':
|
||||
'@vitest/runner@4.1.0':
|
||||
dependencies:
|
||||
'@vitest/utils': 3.2.2
|
||||
'@vitest/utils': 4.1.0
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@3.2.2':
|
||||
'@vitest/snapshot@4.1.0':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 3.2.2
|
||||
magic-string: 0.30.17
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/utils': 4.1.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@3.2.2':
|
||||
dependencies:
|
||||
tinyspy: 4.0.3
|
||||
'@vitest/spy@4.1.0': {}
|
||||
|
||||
'@vitest/utils@3.2.2':
|
||||
'@vitest/utils@4.1.0':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 3.2.2
|
||||
loupe: 3.1.3
|
||||
tinyrainbow: 2.0.0
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
dependencies:
|
||||
@@ -5196,7 +5171,7 @@ snapshots:
|
||||
'@vue/compiler-ssr': 3.5.13
|
||||
'@vue/shared': 3.5.13
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.10
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -5392,7 +5367,7 @@ snapshots:
|
||||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axios@1.15.2(debug@4.4.0):
|
||||
axios@1.16.0(debug@4.4.0):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.0)
|
||||
form-data: 4.0.5
|
||||
@@ -5455,8 +5430,6 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
cachedir@2.4.0: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
@@ -5477,21 +5450,13 @@ snapshots:
|
||||
|
||||
caseless@0.12.0: {}
|
||||
|
||||
chai@5.2.0:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
check-error: 2.1.1
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.1.3
|
||||
pathval: 2.0.0
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
check-error@2.1.1: {}
|
||||
|
||||
check-more-types@2.24.0: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
@@ -5572,6 +5537,8 @@ snapshots:
|
||||
|
||||
convert-source-map@1.9.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
copy-anything@3.0.5:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
@@ -5861,10 +5828,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3(supports-color@8.1.1):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -5876,8 +5839,6 @@ snapshots:
|
||||
decode-uri-component@0.2.2:
|
||||
optional: true
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
@@ -5944,9 +5905,9 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
@@ -5955,7 +5916,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.4
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
@@ -6092,7 +6053,7 @@ snapshots:
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
@@ -6138,7 +6099,7 @@ snapshots:
|
||||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
expect-type@1.2.1: {}
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
@@ -6221,7 +6182,7 @@ snapshots:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
@@ -6253,18 +6214,18 @@ snapshots:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
get-stream@5.2.0:
|
||||
dependencies:
|
||||
@@ -6337,7 +6298,7 @@ snapshots:
|
||||
is-stream: 2.0.1
|
||||
type-fest: 0.8.1
|
||||
|
||||
hasown@2.0.3:
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
@@ -6406,11 +6367,11 @@ snapshots:
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.4
|
||||
|
||||
is-core-module@2.16.2:
|
||||
dependencies:
|
||||
hasown: 2.0.3
|
||||
hasown: 2.0.4
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
@@ -6589,8 +6550,6 @@ snapshots:
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
loupe@3.1.3: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
@@ -6599,9 +6558,9 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
magic-string@0.30.17:
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
map-stream@0.1.0: {}
|
||||
|
||||
@@ -6721,6 +6680,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -6790,8 +6751,6 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pathval@2.0.0: {}
|
||||
|
||||
pause-stream@0.0.11:
|
||||
dependencies:
|
||||
through: 2.3.8
|
||||
@@ -6811,8 +6770,6 @@ snapshots:
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
picomatch@4.0.2: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
@@ -6844,7 +6801,7 @@ snapshots:
|
||||
postcss-load-config@4.0.2(postcss@8.5.10):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.7.1
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
postcss: 8.5.10
|
||||
|
||||
@@ -7267,7 +7224,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
std-env@3.9.0: {}
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stream-combiner@0.0.4:
|
||||
dependencies:
|
||||
@@ -7420,25 +7377,21 @@ snapshots:
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.16:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinypool@1.1.0: {}
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyqueue@2.0.3: {}
|
||||
|
||||
tinyrainbow@2.0.0: {}
|
||||
|
||||
tinyspy@4.0.3: {}
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tippy.js@6.3.7:
|
||||
dependencies:
|
||||
@@ -7542,28 +7495,7 @@ snapshots:
|
||||
core-util-is: 1.0.2
|
||||
extsprintf: 1.3.0
|
||||
|
||||
vite-node@3.2.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.1
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
|
||||
vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@@ -7577,49 +7509,35 @@ snapshots:
|
||||
jiti: 1.21.7
|
||||
sass: 1.99.0
|
||||
stylus: 0.57.0
|
||||
yaml: 2.7.1
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@3.2.2(@types/node@25.8.0)(jiti@1.21.7)(jsdom@29.0.1)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
|
||||
vitest@4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/expect': 3.2.2
|
||||
'@vitest/mocker': 3.2.2(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))
|
||||
'@vitest/pretty-format': 3.2.2
|
||||
'@vitest/runner': 3.2.2
|
||||
'@vitest/snapshot': 3.2.2
|
||||
'@vitest/spy': 3.2.2
|
||||
'@vitest/utils': 3.2.2
|
||||
chai: 5.2.0
|
||||
debug: 4.4.1
|
||||
expect-type: 1.2.1
|
||||
magic-string: 0.30.17
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
'@vitest/spy': 4.1.0
|
||||
'@vitest/utils': 4.1.0
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.2
|
||||
std-env: 3.9.0
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.14
|
||||
tinypool: 1.1.0
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
vite-node: 3.2.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.8.0
|
||||
jsdom: 29.0.1
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vt-pbf@3.1.3:
|
||||
dependencies:
|
||||
@@ -7703,7 +7621,7 @@ snapshots:
|
||||
|
||||
wait-on@8.0.1(debug@4.4.0):
|
||||
dependencies:
|
||||
axios: 1.15.2(debug@4.4.0)
|
||||
axios: 1.16.0(debug@4.4.0)
|
||||
joi: 17.13.3
|
||||
lodash: 4.17.21
|
||||
minimist: 1.2.8
|
||||
@@ -7766,7 +7684,7 @@ snapshots:
|
||||
|
||||
yaml@1.10.3: {}
|
||||
|
||||
yaml@2.7.1: {}
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
|
||||
@@ -6,10 +6,23 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.15s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
@@ -310,7 +323,9 @@
|
||||
[id$="_OLK_SRC_BODY_SECTION"],
|
||||
[id$="_OLK_SRC_BODY_SECTION"] ~ *,
|
||||
[class*="_OutlookMessageHeader"],
|
||||
[class*="_OutlookMessageHeader"] ~ * {
|
||||
[class*="_OutlookMessageHeader"] ~ *,
|
||||
[class*="_yahoo_quoted"],
|
||||
[class*="_gmail_quote_container"] {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-border"></div>
|
||||
<span class="text-xs text-muted-foreground">{{ label }}</span>
|
||||
<div class="h-px flex-1 bg-border"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isToday, isYesterday, format } from 'date-fns'
|
||||
|
||||
const props = defineProps({
|
||||
date: {
|
||||
type: [String, Date],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const label = computed(() => {
|
||||
const date = new Date(props.date)
|
||||
if (isToday(date)) return t('globals.terms.today')
|
||||
if (isYesterday(date)) return t('globals.terms.yesterday')
|
||||
return format(date, 'd MMM yyyy')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DaySeparator.vue'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user