merges main into feat-whatsapp

This commit is contained in:
Abhinav Raut
2026-06-20 21:46:37 +05:30
171 changed files with 6997 additions and 2658 deletions
+11 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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
View File
@@ -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)
}
+3 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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"))
+1
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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)
}
+6
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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>
+10 -3
View File
@@ -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') {
+1
View File
@@ -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 {
+27 -13
View File
@@ -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)
@@ -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'
@@ -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
View File
@@ -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()
+34
View File
@@ -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,
}
})
+257 -134
View File
@@ -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,
+1
View File
@@ -81,6 +81,7 @@ export const useMacroStore = defineStore('macroStore', () => {
return {
macroList,
macroOptions,
currentView,
loadMacros,
setCurrentView
}
+6
View File
@@ -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;
}
+65 -16
View File
@@ -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()
+1 -1
View File
@@ -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>
+21
View File
@@ -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,
}
})
+79 -17
View File
@@ -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()
}
+152
View File
@@ -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()
}
})
})
+29
View File
@@ -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()
})
+2 -2
View File
@@ -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"
}
+164 -246
View File
@@ -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:
+17 -2
View File
@@ -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