mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
feat: Widget dark mode and chat reply expectation message in chat title.
feat: Add HTTP utility functions for trusted origin checks feat: Implement typing status broadcasting for live chat clients and agents. feat: Add support for signed URLs in media manager fix: Update database migration to handle duplicate visitors with same email address. feat: Add conversation subscription and typing message models for WebSocket communication feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator. feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any.
This commit is contained in:
+332
-196
@@ -1,12 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -14,26 +10,18 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/attachment"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: Can have a global route that serves media files with a signature and expiry.
|
||||
// Or use the same existing `/uploads`
|
||||
chatWidgetMediaURL = "/api/v1/widget/media/%s?signature=%s&expires=%d"
|
||||
)
|
||||
|
||||
type onlyJWT struct {
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
@@ -57,8 +45,8 @@ type chatInitReq struct {
|
||||
}
|
||||
|
||||
type conversationResp struct {
|
||||
Conversation models.ChatConversation `json:"conversation"`
|
||||
Messages []models.ChatMessage `json:"messages"`
|
||||
Conversation cmodels.ChatConversation `json:"conversation"`
|
||||
Messages []cmodels.ChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatMessageReq struct {
|
||||
@@ -66,6 +54,59 @@ type chatMessageReq struct {
|
||||
onlyJWT
|
||||
}
|
||||
|
||||
type chatSettingsResponse struct {
|
||||
livechat.Config
|
||||
BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"`
|
||||
DefaultBusinessHoursID int `json:"default_business_hours_id,omitempty"`
|
||||
}
|
||||
|
||||
// conversationResponseWithBusinessHours includes business hours info for the widget
|
||||
type conversationResponseWithBusinessHours struct {
|
||||
conversationResp
|
||||
BusinessHoursID *int `json:"business_hours_id,omitempty"`
|
||||
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
||||
}
|
||||
|
||||
// TODO: live chat widget can have a different language setting than the main app, handle this.
|
||||
//
|
||||
// handleGetChatLauncherSettings returns the live chat launcher settings for the widget
|
||||
func handleGetChatLauncherSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
inboxID = r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
|
||||
)
|
||||
|
||||
if inboxID <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Get inbox configuration
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
|
||||
}
|
||||
|
||||
if inbox.Channel != livechat.ChannelLiveChat {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if !inbox.Enabled {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
var config livechat.Config
|
||||
if err := json.Unmarshal(inbox.Config, &config); err != nil {
|
||||
app.lo.Error("error parsing live chat config", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]any{
|
||||
"launcher": config.Launcher,
|
||||
"colors": config.Colors,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetChatSettings returns the live chat settings for the widget
|
||||
func handleGetChatSettings(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -98,7 +139,35 @@ func handleGetChatSettings(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(config)
|
||||
// Get business hours data if office hours feature is enabled.
|
||||
response := chatSettingsResponse{
|
||||
Config: config,
|
||||
}
|
||||
|
||||
if config.ShowOfficeHoursInChat {
|
||||
// Get all business hours.
|
||||
businessHours, err := app.businessHours.GetAll()
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching business hours", "error", err)
|
||||
} else {
|
||||
response.BusinessHours = businessHours
|
||||
}
|
||||
|
||||
// Get default business hours ID from general settings which is the default / fallback.
|
||||
out, err := app.setting.GetByPrefix("app")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching general settings", "error", err)
|
||||
} else {
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal(out, &settings); err == nil {
|
||||
if bhID, ok := settings["app.business_hours_id"].(string); ok {
|
||||
response.DefaultBusinessHoursID, _ = strconv.Atoi(bhID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r.SendEnvelope(response)
|
||||
}
|
||||
|
||||
// handleChatInit initializes a new chat session.
|
||||
@@ -128,7 +197,6 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
if !inbox.Enabled {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if inbox.Channel != livechat.ChannelLiveChat {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
@@ -139,11 +207,11 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
app.lo.Error("error parsing live chat config", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
var contactID int
|
||||
var conversationUUID string
|
||||
var isVisitor bool
|
||||
|
||||
var (
|
||||
contactID int
|
||||
conversationUUID string
|
||||
isVisitor bool
|
||||
)
|
||||
// Handle authenticated user
|
||||
if req.JWT != "" {
|
||||
claims, err := verifyStandardJWT(req.JWT)
|
||||
@@ -174,6 +242,36 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check conversation permissions based on user type.
|
||||
userConfig := config.Visitors
|
||||
if !isVisitor {
|
||||
userConfig = config.Users
|
||||
}
|
||||
|
||||
if !userConfig.AllowStartConversation {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if userConfig.PreventMultipleConversations {
|
||||
conversations, err := app.conversation.GetContactChatConversations(contactID)
|
||||
if err != nil {
|
||||
userType := "visitor"
|
||||
if !isVisitor {
|
||||
userType = "user"
|
||||
}
|
||||
app.lo.Error("error fetching "+userType+" conversations", "contact_id", contactID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
userType := "visitor"
|
||||
if !isVisitor {
|
||||
userType = "user"
|
||||
}
|
||||
app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", req.InboxID, "is_visitor", isVisitor)
|
||||
|
||||
// Create conversation.
|
||||
@@ -191,14 +289,14 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Insert initial message.
|
||||
message := models.Message{
|
||||
message := cmodels.Message{
|
||||
ConversationUUID: conversationUUID,
|
||||
SenderID: contactID,
|
||||
Type: models.MessageIncoming,
|
||||
SenderType: models.SenderTypeContact,
|
||||
Status: models.MessageStatusReceived,
|
||||
Type: cmodels.MessageIncoming,
|
||||
SenderType: cmodels.SenderTypeContact,
|
||||
Status: cmodels.MessageStatusReceived,
|
||||
Content: req.Message,
|
||||
ContentType: models.ContentTypeText,
|
||||
ContentType: cmodels.ContentTypeText,
|
||||
Private: false,
|
||||
}
|
||||
if err := app.conversation.InsertMessage(&message); err != nil {
|
||||
@@ -211,6 +309,11 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Process post-message hooks for the new conversation and initial message.
|
||||
if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
|
||||
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
|
||||
}
|
||||
|
||||
conversation, err := app.conversation.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching created conversation", "conversation_uuid", conversationUUID, "error", err)
|
||||
@@ -218,15 +321,17 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Build response with conversation and messages.
|
||||
resp, err := buildConversationResponse(app, conversation)
|
||||
resp, err := buildConversationResponseWithBusinessHours(app, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]any{
|
||||
"conversation": resp.Conversation,
|
||||
"messages": resp.Messages,
|
||||
"jwt": req.JWT,
|
||||
"conversation": resp.Conversation,
|
||||
"messages": resp.Messages,
|
||||
"business_hours_id": resp.BusinessHoursID,
|
||||
"working_hours_utc_offset": resp.WorkingHoursUTCOffset,
|
||||
"jwt": req.JWT,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -323,7 +428,7 @@ func handleChatGetConversation(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Build conversation response with messages and attachments.
|
||||
resp, err := buildConversationResponse(app, conversation)
|
||||
resp, err := buildConversationResponseWithBusinessHours(app, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -366,7 +471,7 @@ func handleChatSendMessage(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
||||
req = chatMessageReq{}
|
||||
senderType = models.SenderTypeContact
|
||||
senderType = cmodels.SenderTypeContact
|
||||
senderID = 0
|
||||
)
|
||||
|
||||
@@ -386,6 +491,13 @@ func handleChatSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
senderID = claims.UserID
|
||||
|
||||
// Fetch sender.
|
||||
sender, err := app.user.Get(senderID, "", "")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Fetch conversation to ensure it exists.
|
||||
conversation, err := app.conversation.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
@@ -408,31 +520,57 @@ func handleChatSendMessage(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Insert message.
|
||||
message := models.Message{
|
||||
// Insert incoming message and run post processing hooks.
|
||||
message := cmodels.Message{
|
||||
ConversationUUID: conversationUUID,
|
||||
SenderID: senderID,
|
||||
Type: models.MessageIncoming,
|
||||
Type: cmodels.MessageIncoming,
|
||||
SenderType: senderType,
|
||||
Status: models.MessageStatusReceived,
|
||||
Status: cmodels.MessageStatusReceived,
|
||||
Content: req.Message,
|
||||
ContentType: models.ContentTypeText,
|
||||
ContentType: cmodels.ContentTypeText,
|
||||
Private: false,
|
||||
}
|
||||
|
||||
if err := app.conversation.InsertMessage(&message); err != nil {
|
||||
app.lo.Error("error inserting chat message", "error", err)
|
||||
if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{
|
||||
Channel: livechat.ChannelLiveChat,
|
||||
Message: message,
|
||||
Contact: sender,
|
||||
InboxID: inbox.ID,
|
||||
}); err != nil {
|
||||
app.lo.Error("error processing incoming message", "conversation_uuid", conversationUUID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]bool{"success": true})
|
||||
// Fetch just inserted message to return.
|
||||
message, err = app.conversation.GetMessage(message.UUID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(cmodels.ChatMessage{
|
||||
UUID: message.UUID,
|
||||
CreatedAt: message.CreatedAt,
|
||||
Content: message.Content,
|
||||
TextContent: message.TextContent,
|
||||
ConversationUUID: message.ConversationUUID,
|
||||
Status: message.Status,
|
||||
Author: umodels.ChatUser{
|
||||
ID: sender.ID,
|
||||
FirstName: sender.FirstName,
|
||||
LastName: sender.LastName,
|
||||
AvatarURL: sender.AvatarURL,
|
||||
AvailabilityStatus: sender.AvailabilityStatus,
|
||||
Type: sender.Type,
|
||||
},
|
||||
Attachments: message.Attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// handleWidgetMediaUpload handles media uploads for the widget.
|
||||
func handleWidgetMediaUpload(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
cleanUp = false
|
||||
senderID = 0
|
||||
)
|
||||
|
||||
@@ -466,14 +604,12 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
|
||||
// Set sender ID from JWT claims.
|
||||
senderID = claims.UserID
|
||||
|
||||
// Verify conversation exists and user has access
|
||||
// Make sure the conversation belongs to the sender
|
||||
conversation, err := app.conversation.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Make sure the conversation belongs to the sender
|
||||
if conversation.ContactID != senderID {
|
||||
app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
|
||||
@@ -535,130 +671,65 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Delete files on any error.
|
||||
var uuid = uuid.New()
|
||||
thumbName := thumbPrefix + uuid.String()
|
||||
defer func() {
|
||||
if cleanUp {
|
||||
app.media.Delete(uuid.String())
|
||||
app.media.Delete(thumbName)
|
||||
}
|
||||
}()
|
||||
|
||||
// Generate and upload thumbnail and store image dimensions in the media meta.
|
||||
var meta = []byte("{}")
|
||||
if slices.Contains(image.Exts, srcExt) {
|
||||
file.Seek(0, 0)
|
||||
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
|
||||
if err != nil {
|
||||
app.lo.Error("error creating thumb image", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError)
|
||||
}
|
||||
thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Store image dimensions in media meta, storing dimensions for image previews in future.
|
||||
file.Seek(0, 0)
|
||||
width, height, err := image.GetDimensions(file)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.lo.Error("error getting image dimensions", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
|
||||
}
|
||||
meta, _ = json.Marshal(map[string]any{
|
||||
"width": width,
|
||||
"height": height,
|
||||
})
|
||||
}
|
||||
|
||||
// Read file content into byte slice
|
||||
file.Seek(0, 0)
|
||||
_, err = app.media.Upload(uuid.String(), srcContentType, file)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
return sendErrorEnvelope(r, err)
|
||||
fileContent := make([]byte, srcFileSize)
|
||||
if _, err := file.Read(fileContent); err != nil {
|
||||
app.lo.Error("error reading file content", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Insert message with empty content and after insert link the media to the message.
|
||||
message := models.Message{
|
||||
// Get sender user for ProcessIncomingMessage
|
||||
sender, err := app.user.Get(senderID, "", "")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Create message with attachment using existing infrastructure
|
||||
message := cmodels.Message{
|
||||
ConversationUUID: conversationUUID,
|
||||
SenderID: senderID,
|
||||
Type: models.MessageIncoming,
|
||||
SenderType: models.SenderTypeContact,
|
||||
Status: models.MessageStatusReceived,
|
||||
Type: cmodels.MessageIncoming,
|
||||
SenderType: cmodels.SenderTypeContact,
|
||||
Status: cmodels.MessageStatusReceived,
|
||||
Content: "",
|
||||
ContentType: models.ContentTypeText,
|
||||
ContentType: cmodels.ContentTypeText,
|
||||
Private: false,
|
||||
}
|
||||
if err := app.conversation.InsertMessage(&message); err != nil {
|
||||
cleanUp = true
|
||||
app.lo.Error("error inserting message", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
Attachments: attachment.Attachments{
|
||||
{
|
||||
Name: srcFileName,
|
||||
ContentType: srcContentType,
|
||||
Size: int(srcFileSize),
|
||||
Content: fileContent,
|
||||
Disposition: attachment.DispositionAttachment,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Insert media linked to the just inserted message.
|
||||
media, err := app.media.Insert(null.StringFrom(attachment.DispositionAttachment), srcFileName, srcContentType, "" /**content_id**/, null.NewString("messages", true), uuid.String(), null.NewInt(message.ID, true), int(srcFileSize), meta)
|
||||
// Process the incoming message with attachment.
|
||||
if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{
|
||||
Channel: livechat.ChannelLiveChat,
|
||||
Message: message,
|
||||
Contact: sender,
|
||||
InboxID: inbox.ID,
|
||||
}); err != nil {
|
||||
app.lo.Error("error processing incoming message with attachment", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Fetch the inserted message to get the media information.
|
||||
insertedMessage, err := app.conversation.GetMessage(message.UUID)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.lo.Error("error inserting metadata into database", "error", err)
|
||||
return sendErrorEnvelope(r, err)
|
||||
app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(media)
|
||||
}
|
||||
|
||||
// handleWidgetServeMedia serves media files for the widget
|
||||
func handleWidgetServeMedia(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
signature = string(r.RequestCtx.QueryArgs().Peek("signature"))
|
||||
expiresStr = string(r.RequestCtx.QueryArgs().Peek("expires"))
|
||||
)
|
||||
|
||||
if uuid == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "uuid"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if signature == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Signature missing", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if expiresStr == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Expiry missing", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
expires, err := strconv.ParseInt(expiresStr, 10, 64)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid expiry", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Verify signature and expiration.
|
||||
expiresAt := time.Unix(expires, 0)
|
||||
if !VerifySignedURL(uuid, signature, expiresAt, getJWTSecret()) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
|
||||
}
|
||||
|
||||
// Get media DB record.
|
||||
_, err = app.media.Get(0, uuid)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
consts := app.consts.Load().(*constants)
|
||||
switch consts.UploadProvider {
|
||||
case "fs":
|
||||
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
|
||||
case "s3":
|
||||
r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
|
||||
}
|
||||
return nil
|
||||
return r.SendEnvelope(insertedMessage)
|
||||
}
|
||||
|
||||
// buildConversationResponse builds the response for a conversation including its messages
|
||||
func buildConversationResponse(app *App, conversation models.Conversation) (conversationResp, error) {
|
||||
func buildConversationResponse(app *App, conversation cmodels.Conversation) (conversationResp, error) {
|
||||
var resp = conversationResp{}
|
||||
|
||||
// Fetch last 1000 messages, this should suffice as chats shouldn't have too many messages.
|
||||
@@ -669,22 +740,48 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv
|
||||
return resp, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil)
|
||||
}
|
||||
|
||||
app.conversation.ProcessCSATStatus(messages)
|
||||
|
||||
// Convert to chat message format, Generate signed widget URL for all attachments - expires in 1 hour.
|
||||
chatMessages := make([]models.ChatMessage, len(messages))
|
||||
chatMessages := make([]cmodels.ChatMessage, len(messages))
|
||||
userCache := make(map[int]umodels.User)
|
||||
for i, msg := range messages {
|
||||
attachments := msg.Attachments
|
||||
for j := range attachments {
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
signature := GenerateSignedURL(attachments[j].UUID, expiresAt, getJWTSecret())
|
||||
attachments[j].URL = fmt.Sprintf(chatWidgetMediaURL, attachments[j].UUID, signature, expiresAt.Unix())
|
||||
expiresAt := time.Now().Add(8 * time.Hour)
|
||||
attachments[j].URL = app.media.GetSignedURL(attachments[j].UUID, expiresAt)
|
||||
}
|
||||
chatMessages[i] = models.ChatMessage{
|
||||
UUID: msg.UUID,
|
||||
Content: msg.Content,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
SenderType: msg.SenderType,
|
||||
ConversationID: msg.ConversationUUID,
|
||||
Attachments: attachments,
|
||||
|
||||
// Check if sender is cached, if not fetch from user store.
|
||||
var user umodels.User
|
||||
if _, ok := userCache[msg.SenderID]; !ok {
|
||||
user, err = app.user.Get(msg.SenderID, "", "")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching message sender user", "sender_id", msg.SenderID, "conversation_uuid", conversation.UUID, "error", err)
|
||||
} else {
|
||||
userCache[msg.SenderID] = user
|
||||
}
|
||||
} else {
|
||||
user = userCache[msg.SenderID]
|
||||
}
|
||||
|
||||
chatMessages[i] = cmodels.ChatMessage{
|
||||
UUID: msg.UUID,
|
||||
Status: msg.Status,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
Content: msg.Content,
|
||||
TextContent: msg.TextContent,
|
||||
ConversationUUID: msg.ConversationUUID,
|
||||
Meta: msg.Meta,
|
||||
Author: umodels.ChatUser{
|
||||
ID: user.ID,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
AvatarURL: user.AvatarURL,
|
||||
AvailabilityStatus: user.AvailabilityStatus,
|
||||
Type: user.Type,
|
||||
},
|
||||
Attachments: attachments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,24 +795,32 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv
|
||||
}
|
||||
|
||||
// Convert assignee avatar URL to widget format if set.
|
||||
// TODO: Instead of this hardcoded URL, make it a central handler.
|
||||
if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" {
|
||||
avatarPath := assignee.AvatarURL.String
|
||||
if strings.HasPrefix(avatarPath, "/uploads/") {
|
||||
avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/")
|
||||
// Generate signed URL for avatar with 1 hour expiry
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
signature := GenerateSignedURL(avatarUUID, expiresAt, getJWTSecret())
|
||||
assignee.AvatarURL = null.StringFrom(fmt.Sprintf(chatWidgetMediaURL, avatarUUID, signature, expiresAt.Unix()))
|
||||
assignee.AvatarURL = null.StringFrom(app.media.GetSignedURL(avatarUUID, expiresAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp = conversationResp{
|
||||
Conversation: models.ChatConversation{
|
||||
UUID: conversation.UUID,
|
||||
Status: conversation.Status.String,
|
||||
Assignee: assignee,
|
||||
Conversation: cmodels.ChatConversation{
|
||||
CreatedAt: assignee.CreatedAt,
|
||||
UUID: conversation.UUID,
|
||||
Status: conversation.Status.String,
|
||||
UnreadMessageCount: conversation.UnreadMessageCount,
|
||||
Assignee: umodels.ChatUser{
|
||||
ID: assignee.ID,
|
||||
FirstName: assignee.FirstName,
|
||||
LastName: assignee.LastName,
|
||||
AvatarURL: assignee.AvatarURL,
|
||||
AvailabilityStatus: assignee.AvailabilityStatus,
|
||||
Type: assignee.Type,
|
||||
ActiveAt: assignee.LastActiveAt,
|
||||
},
|
||||
},
|
||||
Messages: chatMessages,
|
||||
}
|
||||
@@ -723,33 +828,64 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GenerateSignedURL generates a signature for media access with expiration
|
||||
func GenerateSignedURL(uuid string, expiresAt time.Time, secret []byte) string {
|
||||
exp := expiresAt.Unix()
|
||||
payload := fmt.Sprintf("%s:%d", uuid, exp)
|
||||
sig := hmacSha256(payload, secret)
|
||||
return sig
|
||||
}
|
||||
|
||||
// VerifySignedURL verifies a signed URL with expiration
|
||||
func VerifySignedURL(uuid, signature string, expiresAt time.Time, secret []byte) bool {
|
||||
// Check if expired
|
||||
if time.Now().After(expiresAt) {
|
||||
return false
|
||||
// buildConversationResponseWithBusinessHours builds conversation response with business hours info
|
||||
func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) {
|
||||
baseResp, err := buildConversationResponse(app, conversation)
|
||||
if err != nil {
|
||||
return conversationResponseWithBusinessHours{}, err
|
||||
}
|
||||
|
||||
// Generate expected signature
|
||||
expectedSignature := GenerateSignedURL(uuid, expiresAt, secret)
|
||||
resp := conversationResponseWithBusinessHours{
|
||||
conversationResp: baseResp,
|
||||
}
|
||||
|
||||
// Compare signatures using constant time comparison to prevent timing attacks
|
||||
return hmac.Equal([]byte(signature), []byte(expectedSignature))
|
||||
}
|
||||
// Calculate business hours info if assigned to team or use default
|
||||
var businessHoursID *int
|
||||
var timezone string
|
||||
|
||||
// hmacSha256 generates HMAC-SHA256 hash
|
||||
func hmacSha256(data string, secret []byte) string {
|
||||
h := hmac.New(sha256.New, secret)
|
||||
h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
// Check if conversation is assigned to a team with business hours
|
||||
if conversation.AssignedTeamID.Valid {
|
||||
team, err := app.team.Get(conversation.AssignedTeamID.Int)
|
||||
if err == nil && team.BusinessHoursID.Valid {
|
||||
businessHoursID = &team.BusinessHoursID.Int
|
||||
timezone = team.Timezone
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to general settings if no team business hours
|
||||
if businessHoursID == nil {
|
||||
out, err := app.setting.GetByPrefix("app")
|
||||
if err == nil {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(out, &settings); err == nil {
|
||||
if bhIDStr, ok := settings["app.business_hours_id"].(string); ok && bhIDStr != "" {
|
||||
// Parse the business hours ID
|
||||
if bhID, err := strconv.Atoi(bhIDStr); err == nil {
|
||||
businessHoursID = &bhID
|
||||
}
|
||||
}
|
||||
if tz, ok := settings["app.timezone"].(string); ok {
|
||||
timezone = tz
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set business hours info in response
|
||||
if businessHoursID != nil {
|
||||
resp.BusinessHoursID = businessHoursID
|
||||
|
||||
// Calculate UTC offset for the timezone
|
||||
if timezone != "" {
|
||||
if loc, err := time.LoadLocation(timezone); err == nil {
|
||||
_, offset := time.Now().In(loc).Zone()
|
||||
offsetMinutes := offset / 60 // Convert seconds to minutes
|
||||
resp.WorkingHoursUTCOffset = &offsetMinutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// verifyJWT verifies and validates a JWT token with proper signature verification
|
||||
|
||||
@@ -474,11 +474,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Make sure a user is assigned before resolving conversation.
|
||||
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
|
||||
}
|
||||
|
||||
// Update conversation status.
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
|
||||
+42
-2
@@ -3,9 +3,16 @@ package main
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
type csatResponse struct {
|
||||
Rating int `json:"rating"`
|
||||
Feedback string `json:"feedback"`
|
||||
}
|
||||
|
||||
// handleShowCSAT renders the CSAT page for a given csat.
|
||||
func handleShowCSAT(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Rate your interaction with us",
|
||||
"Title": "Rate your interaction with us",
|
||||
"CSAT": map[string]interface{}{
|
||||
"UUID": csat.UUID,
|
||||
},
|
||||
@@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
})
|
||||
}
|
||||
|
||||
if ratingI < 1 || ratingI > 5 {
|
||||
if ratingI < 0 || ratingI > 5 {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"ErrorMessage": "Invalid `rating`",
|
||||
@@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
|
||||
func handleSubmitCSATResponse(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
req = csatResponse{}
|
||||
)
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if req.Rating < 0 || req.Rating > 5 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// At least one of rating or feedback must be provided
|
||||
if req.Rating == 0 && req.Feedback == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if uuid == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Update CSAT response
|
||||
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
+14
-9
@@ -210,21 +210,26 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// Actvity logs.
|
||||
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
|
||||
|
||||
// CSAT.
|
||||
g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
}))
|
||||
|
||||
// Live chat widget.
|
||||
// Live chat widget websocket.
|
||||
g.GET("/widget/ws", handleWidgetWS)
|
||||
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
|
||||
g.POST("/api/v1/widget/chat/conversations/init", handleChatInit)
|
||||
g.POST("/api/v1/widget/chat/conversations", handleGetConversations)
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", handleChatUpdateLastSeen)
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}", handleChatGetConversation)
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", handleChatSendMessage)
|
||||
g.POST("/api/v1/widget/media/upload", handleWidgetMediaUpload)
|
||||
g.GET("/api/v1/widget/media/{uuid}", handleWidgetServeMedia)
|
||||
|
||||
// Widget APIs.
|
||||
g.GET("/api/v1/widget/chat/settings/launcher", widgetOrigin(handleGetChatLauncherSettings))
|
||||
g.GET("/api/v1/widget/chat/settings", widgetOrigin(handleGetChatSettings))
|
||||
g.POST("/api/v1/widget/chat/conversations/init", widgetOrigin(handleChatInit))
|
||||
g.POST("/api/v1/widget/chat/conversations", widgetOrigin(handleGetConversations))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetOrigin(handleChatUpdateLastSeen))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetOrigin(handleChatGetConversation))
|
||||
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetOrigin(handleChatSendMessage))
|
||||
g.POST("/api/v1/widget/media/upload", widgetOrigin(handleWidgetMediaUpload))
|
||||
|
||||
// Frontend pages.
|
||||
g.GET("/", notAuthPage(serveIndexPage))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -169,5 +171,17 @@ func validateInbox(app *App, inbox imodels.Inbox) error {
|
||||
if inbox.Channel == "" {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
|
||||
}
|
||||
|
||||
// Validate livechat-specific configuration
|
||||
if inbox.Channel == livechat.ChannelLiveChat {
|
||||
var config livechat.Config
|
||||
if err := json.Unmarshal(inbox.Config, &config); err == nil {
|
||||
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
|
||||
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
|
||||
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-4
@@ -462,10 +462,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
|
||||
}
|
||||
|
||||
media, err := media.New(media.Opts{
|
||||
Store: store,
|
||||
Lo: lo,
|
||||
DB: db,
|
||||
I18n: i18n,
|
||||
Store: store,
|
||||
Lo: lo,
|
||||
DB: db,
|
||||
I18n: i18n,
|
||||
Secret: ko.String("upload.secret"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing media: %v", err)
|
||||
|
||||
@@ -203,9 +203,13 @@ func main() {
|
||||
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
|
||||
autoassigner = initAutoAssigner(team, user, conversation)
|
||||
)
|
||||
|
||||
wsHub.SetConversationStore(conversation)
|
||||
automation.SetConversationStore(conversation)
|
||||
|
||||
// Start inboxes.
|
||||
startInboxes(ctx, inbox, conversation, user)
|
||||
|
||||
go automation.Run(ctx, automationWorkers)
|
||||
go autoassigner.Run(ctx, autoAssignInterval)
|
||||
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
|
||||
|
||||
+33
-27
@@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// handleServeMedia serves uploaded media.
|
||||
// Supports both authenticated agent access and unauthenticated access via signed URLs.
|
||||
func handleServeMedia(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
app = r.Context.(*App)
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Fetch media from DB.
|
||||
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Check if the user has permission to access the linked model.
|
||||
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// For messages, check access to the conversation this message is part of.
|
||||
if media.Model.String == "messages" {
|
||||
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
|
||||
// Check if user is authenticated (agent access)
|
||||
auser := r.RequestCtx.UserValue("user")
|
||||
if auser != nil {
|
||||
// Authenticated.
|
||||
user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
|
||||
|
||||
// Fetch media from DB.
|
||||
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
|
||||
// Check if the user has permission to access the linked model.
|
||||
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// For messages, check access to the conversation this message is part of.
|
||||
if media.Model.String == "messages" {
|
||||
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
|
||||
}
|
||||
}
|
||||
// If no authenticated user, the middleware has already verified the request signature serve the file.
|
||||
consts := app.consts.Load().(*constants)
|
||||
switch consts.UploadProvider {
|
||||
case "fs":
|
||||
|
||||
+7
-4
@@ -53,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error {
|
||||
for j := range messages[i].Attachments {
|
||||
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
|
||||
}
|
||||
// Redact CSAT survey link
|
||||
messages[i].CensorCSATContent()
|
||||
}
|
||||
|
||||
// Process CSAT status for all messages (will only affect CSAT messages)
|
||||
app.conversation.ProcessCSATStatus(messages)
|
||||
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Total: total,
|
||||
Results: messages,
|
||||
@@ -90,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Redact CSAT survey link
|
||||
message.CensorCSATContent()
|
||||
// Process CSAT status for the message (will only affect CSAT messages)
|
||||
messages := []cmodels.Message{message}
|
||||
app.conversation.ProcessCSATStatus(messages)
|
||||
message = messages[0]
|
||||
|
||||
for j := range message.Attachments {
|
||||
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
|
||||
|
||||
@@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// For media uploads, check if signature is provided in the query parameters, if so, verify it.
|
||||
path := string(r.RequestCtx.Path())
|
||||
if strings.HasPrefix(path, "/uploads/") {
|
||||
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
|
||||
expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
|
||||
|
||||
if signature != "" && expires != "" {
|
||||
if err := app.media.VerifySignature(r); err != nil {
|
||||
app.lo.Error("error verifying media signature", "error",
|
||||
err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
|
||||
}
|
||||
return handler(r)
|
||||
}
|
||||
// If no signature, continue with normal authentication.
|
||||
}
|
||||
|
||||
// Authenticate user using shared authentication logic
|
||||
user, err := authenticateUser(r, app)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/httputil"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
type inboxReq struct {
|
||||
InboxID int `json:"inbox_id"`
|
||||
}
|
||||
|
||||
// widgetOrigin middleware validates the Origin header against trusted domains configured in the live chat inbox settings.
|
||||
func widgetOrigin(next fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
|
||||
// Get the Origin header from the request
|
||||
origin := string(r.RequestCtx.Request.Header.Peek("Origin"))
|
||||
|
||||
// If no origin header is present, allow direct access.
|
||||
if origin == "" {
|
||||
return next(r)
|
||||
}
|
||||
|
||||
// Extract inbox ID from request
|
||||
var inboxID int
|
||||
|
||||
// Search for inbox_id in query parameters first.
|
||||
if qInboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id"); qInboxID > 0 {
|
||||
inboxID = qInboxID
|
||||
} else {
|
||||
// For POST/PUT requests, try to decode the body to get `inbox_id`, every request sends it.
|
||||
if r.RequestCtx.IsPost() || r.RequestCtx.IsPut() {
|
||||
inboxReq := inboxReq{}
|
||||
bodyBytes := r.RequestCtx.Request.Body()
|
||||
if len(bodyBytes) > 0 {
|
||||
if err := json.Unmarshal(bodyBytes, &inboxReq); err == nil {
|
||||
inboxID = inboxReq.InboxID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check multipart form data for `inbox_id` if not found in query or body.
|
||||
if inboxID == 0 {
|
||||
if r.RequestCtx.Request.Header.IsPost() {
|
||||
form, err := r.RequestCtx.MultipartForm()
|
||||
if err == nil && form != nil && form.Value != nil {
|
||||
if inboxIDValues, exists := form.Value["inbox_id"]; exists && len(inboxIDValues) > 0 {
|
||||
if parsedID, parseErr := strconv.Atoi(inboxIDValues[0]); parseErr == nil && parsedID > 0 {
|
||||
inboxID = parsedID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't determine the inbox ID, disallow the request
|
||||
if inboxID <= 0 {
|
||||
app.lo.Warn("widget request without inbox_id blocked", "path", string(r.RequestCtx.Path()))
|
||||
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Get inbox configuration
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching inbox for origin check", "inbox_id", inboxID, "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
|
||||
}
|
||||
|
||||
if !inbox.Enabled {
|
||||
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Parse the live chat config
|
||||
var config livechat.Config
|
||||
if err := json.Unmarshal(inbox.Config, &config); err != nil {
|
||||
app.lo.Error("error parsing live chat config for origin check", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// If trusted domains list is empty, allow all origins
|
||||
if len(config.TrustedDomains) == 0 {
|
||||
return next(r)
|
||||
}
|
||||
|
||||
// Check if the origin matches any of the trusted domains
|
||||
if !httputil.IsOriginTrusted(origin, config.TrustedDomains) {
|
||||
app.lo.Warn("widget request from untrusted origin blocked",
|
||||
"origin", origin,
|
||||
"inbox_id", inboxID,
|
||||
"trusted_domains", config.TrustedDomains)
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+origin, nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
app.lo.Debug("widget request from trusted origin allowed", "origin", origin, "inbox_id", inboxID)
|
||||
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
+24
-30
@@ -24,14 +24,13 @@ const (
|
||||
|
||||
// WidgetMessage represents a message sent through the widget WebSocket
|
||||
type WidgetMessage struct {
|
||||
Type string `json:"type"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
Data interface{} `json:"data"`
|
||||
Type string `json:"type"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// WidgetJoinData represents data for joining a conversation
|
||||
type WidgetJoinData struct {
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
type WidgetInboxJoinRequest struct {
|
||||
InboxID int `json:"inbox_id"`
|
||||
}
|
||||
|
||||
// WidgetMessageData represents a chat message through the widget
|
||||
@@ -91,11 +90,11 @@ func handleWidgetWS(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
// Join conversation request.
|
||||
// Inbox join request.
|
||||
case WidgetMsgTypeJoin:
|
||||
var joinedClient *livechat.Client
|
||||
var joinedLiveChat *livechat.LiveChat
|
||||
if joinedClient, joinedLiveChat, err = handleWidgetJoin(app, conn, &msg, claims); err != nil {
|
||||
if joinedClient, joinedLiveChat, err = handleInboxJoin(app, conn, &msg, claims); err != nil {
|
||||
app.lo.Error("error handling widget join", "error", err)
|
||||
sendWidgetError(conn, "Failed to join conversation")
|
||||
continue
|
||||
@@ -124,8 +123,8 @@ func handleWidgetWS(r *fastglue.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWidgetJoin handles a client joining a conversation
|
||||
func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) {
|
||||
// handleInboxJoin handles a websocket join request for a live chat inbox.
|
||||
func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) {
|
||||
userID := claims.UserID
|
||||
|
||||
joinDataBytes, err := json.Marshal(msg.Data)
|
||||
@@ -133,28 +132,16 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims
|
||||
return nil, nil, fmt.Errorf("invalid join data: %w", err)
|
||||
}
|
||||
|
||||
var joinData WidgetJoinData
|
||||
var joinData WidgetInboxJoinRequest
|
||||
if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid join data format: %w", err)
|
||||
}
|
||||
|
||||
// Get conversation to find the inbox
|
||||
conversation, err := app.conversation.GetConversation(0, joinData.ConversationUUID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("conversation not found: %w", err)
|
||||
}
|
||||
|
||||
// Make sure conversation belongs to the user.
|
||||
if conversation.ContactID != userID {
|
||||
return nil, nil, fmt.Errorf("conversation does not belong to the user")
|
||||
}
|
||||
|
||||
// Make sure inbox is active.
|
||||
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
|
||||
inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("inbox not found: %w", err)
|
||||
}
|
||||
|
||||
if !inbox.Enabled {
|
||||
return nil, nil, fmt.Errorf("inbox is not enabled")
|
||||
}
|
||||
@@ -173,9 +160,9 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims
|
||||
|
||||
// Add client to live chat session
|
||||
userIDStr := fmt.Sprintf("%d", userID)
|
||||
client, err := liveChat.AddClient(userIDStr, joinData.ConversationUUID)
|
||||
client, err := liveChat.AddClient(userIDStr)
|
||||
if err != nil {
|
||||
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr, "conversation_uuid", joinData.ConversationUUID)
|
||||
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -193,8 +180,7 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims
|
||||
joinResp := WidgetMessage{
|
||||
Type: WidgetMsgTypeJoined,
|
||||
Data: map[string]string{
|
||||
"message": "Joined conversation successfully",
|
||||
"conversation_uuid": joinData.ConversationUUID,
|
||||
"message": "namaste!",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -202,6 +188,8 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
|
||||
|
||||
return client, liveChat, nil
|
||||
}
|
||||
|
||||
@@ -219,8 +207,14 @@ func handleWidgetTyping(app *App, msg *WidgetMessage, claims Claims) error {
|
||||
app.lo.Error("error unmarshalling typing data", "error", err)
|
||||
return fmt.Errorf("invalid typing data format: %w", err)
|
||||
}
|
||||
// Broadcast this to agents.
|
||||
app.lo.Debug("Received typing data for user", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
|
||||
|
||||
// Broadcast typing status to agents via conversation manager
|
||||
// Set broadcastToWidgets=false to avoid echoing back to widget clients
|
||||
if typingData.ConversationUUID != "" {
|
||||
app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
|
||||
}
|
||||
|
||||
app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import { useTypingIndicator } from '@shared-ui/composables'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
|
||||
const textContent = defineModel('textContent', { default: '' })
|
||||
const htmlContent = defineModel('htmlContent', { default: '' })
|
||||
@@ -141,6 +143,10 @@ const emit = defineEmits(['send', 'aiPromptSelected'])
|
||||
|
||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
// Set up typing indicator
|
||||
const conversationStore = useConversationStore()
|
||||
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
|
||||
|
||||
// To preseve the table styling in emails, need to set the table style inline.
|
||||
// Created these custom extensions to set the table style inline.
|
||||
const CustomTable = Table.extend({
|
||||
@@ -201,6 +207,8 @@ const editor = useEditor({
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
emit('send')
|
||||
// Stop typing when sending
|
||||
stopTyping()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -211,6 +219,13 @@ const editor = useEditor({
|
||||
htmlContent.value = editor.getHTML()
|
||||
textContent.value = editor.getText()
|
||||
isInternalUpdate.value = false
|
||||
|
||||
// Trigger typing indicator when user types
|
||||
startTyping()
|
||||
},
|
||||
onBlur: () => {
|
||||
// Stop typing when editor loses focus
|
||||
stopTyping()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,4 +2,12 @@ export const WS_EVENT = {
|
||||
NEW_MESSAGE: 'new_message',
|
||||
MESSAGE_PROP_UPDATE: 'message_prop_update',
|
||||
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
|
||||
}
|
||||
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
|
||||
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
|
||||
TYPING: 'typing',
|
||||
}
|
||||
|
||||
// Message types that should not be queued because they become stale quickly
|
||||
export const WS_EPHEMERAL_TYPES = [
|
||||
WS_EVENT.TYPING,
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,21 @@ export const createFormSchema = (t) => z.object({
|
||||
csat_enabled: z.boolean(),
|
||||
config: z.object({
|
||||
brand_name: z.string().min(1, { message: t('globals.messages.required') }),
|
||||
logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')),
|
||||
dark_mode: z.boolean(),
|
||||
language: z.string().min(1, { message: t('globals.messages.required') }),
|
||||
logo_url: z.string().url({
|
||||
message: t('globals.messages.invalid', {
|
||||
name: t('globals.terms.url').toLowerCase()
|
||||
})
|
||||
}).optional().or(z.literal('')),
|
||||
secret_key: z.string().optional(),
|
||||
launcher: z.object({
|
||||
position: z.enum(['left', 'right']),
|
||||
logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')),
|
||||
logo_url: z.string().url({
|
||||
message: t('globals.messages.invalid', {
|
||||
name: t('globals.terms.url').toLowerCase()
|
||||
})
|
||||
}).optional().or(z.literal('')),
|
||||
spacing: z.object({
|
||||
side: z.number().min(0),
|
||||
bottom: z.number().min(0),
|
||||
@@ -39,7 +49,11 @@ export const createFormSchema = (t) => z.object({
|
||||
trusted_domains: z.string().optional(),
|
||||
external_links: z.array(z.object({
|
||||
text: z.string().min(1),
|
||||
url: z.string().url({ message: t('globals.messages.invalidUrl') })
|
||||
url: z.string().url({
|
||||
message: t('globals.messages.invalid', {
|
||||
name: t('globals.terms.url').toLowerCase()
|
||||
})
|
||||
})
|
||||
})),
|
||||
visitors: z.object({
|
||||
start_conversation_button_text: z.string(),
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||
/>
|
||||
|
||||
<!-- CSAT Response Display -->
|
||||
<CSATResponseDisplay :message="message" />
|
||||
|
||||
<!-- Attachments -->
|
||||
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
|
||||
|
||||
@@ -88,6 +91,7 @@ import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
|
||||
import MessageEnvelope from './MessageEnvelope.vue'
|
||||
import CSATResponseDisplay from './CSATResponseDisplay.vue'
|
||||
import api from '../../../api'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div v-if="csatResponse" class="mt-3 p-3 bg-muted/50 rounded-md border">
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
||||
{{ $t('globals.terms.feedback') }}:
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<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-xs text-muted-foreground">({{ csatResponse.rating }}/5)</span>
|
||||
</div>
|
||||
|
||||
<!-- Feedback -->
|
||||
<div v-if="csatResponse.feedback" class="text-sm italic text-foreground">
|
||||
"{{ csatResponse.feedback }}"
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const csatResponse = computed(() => {
|
||||
const meta = props.message.meta
|
||||
if (!meta?.is_csat || !meta?.csat_submitted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
rating: meta.submitted_rating || null,
|
||||
feedback: meta.submitted_feedback || null
|
||||
}
|
||||
})
|
||||
|
||||
const getRatingEmoji = (rating) => {
|
||||
const ratings = {
|
||||
1: '😢',
|
||||
2: '😕',
|
||||
3: '😊',
|
||||
4: '😃',
|
||||
5: '🤩'
|
||||
}
|
||||
return ratings[rating] || ''
|
||||
}
|
||||
|
||||
const getRatingText = (rating) => {
|
||||
const ratings = {
|
||||
1: 'Poor',
|
||||
2: 'Fair',
|
||||
3: 'Good',
|
||||
4: 'Great',
|
||||
5: 'Excellent'
|
||||
}
|
||||
return ratings[rating] || ''
|
||||
}
|
||||
</script>
|
||||
@@ -43,33 +43,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="conversationStore.conversation.isTyping">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky container for the scroll arrow -->
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
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-1"
|
||||
>
|
||||
<div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
|
||||
<button
|
||||
@click="handleScrollToBottom"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-background text-primary transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<ChevronDown size="18" />
|
||||
</button>
|
||||
<span
|
||||
v-if="unReadMessages > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[20px] h-5 px-1.5 rounded-full bg-green-500 text-secondary text-xs font-medium flex items-center justify-center"
|
||||
>
|
||||
{{ unReadMessages }}
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<ScrollToBottomButton
|
||||
:is-at-bottom="isAtBottom"
|
||||
:unread-count="unReadMessages"
|
||||
@scroll-to-bottom="handleScrollToBottom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,10 +68,12 @@ import AgentMessageBubble from './AgentMessageBubble.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 } from 'lucide-vue-next'
|
||||
import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton'
|
||||
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'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -108,7 +97,6 @@ const handleScroll = () => {
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
unReadMessages.value = 0
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
@@ -132,7 +120,11 @@ const handleNewMessage = () => {
|
||||
if (data.conversation_uuid === conversationStore.current.uuid) {
|
||||
if (data.message?.sender_id === userStore.userID) {
|
||||
scrollToBottom()
|
||||
} else if (!isAtBottom.value) {
|
||||
} else if (isAtBottom.value) {
|
||||
// If user is at bottom, scroll to show new message
|
||||
scrollToBottom()
|
||||
} else {
|
||||
// If user is not at bottom, increment unread counter but do not scroll
|
||||
unReadMessages.value++
|
||||
}
|
||||
}
|
||||
@@ -156,6 +148,26 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Watch for typing indicator and auto-scroll if user is at bottom
|
||||
watch(
|
||||
() => conversationStore.conversation.isTyping,
|
||||
(isTyping) => {
|
||||
if (isTyping && isAtBottom.value) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { handleHTTPError } from '../utils/http'
|
||||
import { computeRecipientsFromMessage } from '../utils/email-recipients'
|
||||
import { useEmitter } from '../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../constants/emitterEvents'
|
||||
import { subscribeToConversation, sendTypingIndicator } from '@main/websocket'
|
||||
import MessageCache from '../utils/conversation-message-cache'
|
||||
import api from '../api'
|
||||
|
||||
@@ -101,7 +102,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
data: null,
|
||||
participants: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
errorMessage: '',
|
||||
isTyping: false
|
||||
})
|
||||
|
||||
const messages = reactive({
|
||||
@@ -242,6 +244,15 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const conv = conversation.data
|
||||
const msgData = messages.data
|
||||
const inboxEmail = conv?.inbox_mail
|
||||
|
||||
// If the conversation is a live chat, reset recipients.
|
||||
if (conv?.inbox_channel === 'livechat') {
|
||||
currentTo.value = []
|
||||
currentCC.value = []
|
||||
currentBCC.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (!conv || !msgData || !inboxEmail) return
|
||||
|
||||
const latestMessage = msgData.getLatestMessage(conv.uuid, ['incoming', 'outgoing'], true)
|
||||
@@ -284,6 +295,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
try {
|
||||
const resp = await api.getConversation(uuid)
|
||||
conversation.data = resp.data.data
|
||||
// Do a websocket subscription to the conversation.
|
||||
subscribeToConversation(uuid)
|
||||
} catch (error) {
|
||||
conversation.errorMessage = handleHTTPError(error).message
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -643,23 +656,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function resetCurrentConversation () {
|
||||
Object.assign(conversation, {
|
||||
data: null,
|
||||
participants: {},
|
||||
macro: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
})
|
||||
}
|
||||
|
||||
function resetConversations () {
|
||||
conversations.data = []
|
||||
conversations.page = 1
|
||||
seenConversationUUIDs = new Map()
|
||||
}
|
||||
|
||||
|
||||
/** Macros set for new conversation or an open conversation **/
|
||||
async function setMacro (macro, context) {
|
||||
macros.value[context] = macro
|
||||
@@ -678,6 +680,22 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
macros.value = { ...macros.value, [context]: {} }
|
||||
}
|
||||
|
||||
// Typing indicators
|
||||
function updateTypingStatus (typingData) {
|
||||
const { conversation_uuid, is_typing } = typingData
|
||||
|
||||
// Only update typing status for the current conversation
|
||||
if (conversation.data?.uuid !== conversation_uuid) return
|
||||
|
||||
conversation.isTyping = is_typing
|
||||
}
|
||||
|
||||
function sendTyping (isTyping) {
|
||||
if (conversation.data?.uuid) {
|
||||
sendTypingIndicator(conversation.data.uuid, isTyping)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
conversation,
|
||||
@@ -710,7 +728,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
updatePriority,
|
||||
updateStatus,
|
||||
updateConversationList,
|
||||
resetCurrentConversation,
|
||||
fetchFirstPageConversations,
|
||||
fetchStatuses,
|
||||
fetchPriorities,
|
||||
@@ -727,6 +744,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
priorities,
|
||||
priorityOptions,
|
||||
statusOptionsNoSnooze,
|
||||
statusOptions
|
||||
statusOptions,
|
||||
updateTypingStatus,
|
||||
sendTyping
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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) {
|
||||
@@ -129,6 +131,20 @@ export default class MessageCache {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a message from the cache
|
||||
*/
|
||||
removeMessage (convId, msgId) {
|
||||
const conv = this.cache.get(convId)
|
||||
if (!conv) return
|
||||
conv.pages.forEach(msgs => {
|
||||
const msgIndex = msgs.findIndex(m => m.uuid === msgId)
|
||||
if (msgIndex !== -1) {
|
||||
msgs.splice(msgIndex, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if conversation has more pages to fetch
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useConversationStore } from './stores/conversation'
|
||||
import { WS_EVENT } from './constants/websocket'
|
||||
import { WS_EVENT, WS_EPHEMERAL_TYPES } from './constants/websocket'
|
||||
|
||||
export class WebSocketClient {
|
||||
constructor() {
|
||||
@@ -13,6 +13,10 @@ export class WebSocketClient {
|
||||
this.pingInterval = null
|
||||
this.lastPong = Date.now()
|
||||
this.convStore = useConversationStore()
|
||||
this.messageQueue = []
|
||||
this.maxQueueSize = 50
|
||||
// 30 sec.
|
||||
this.queueTimeoutMs = 30000
|
||||
}
|
||||
|
||||
init () {
|
||||
@@ -42,6 +46,8 @@ export class WebSocketClient {
|
||||
this.isReconnecting = false
|
||||
this.lastPong = Date.now()
|
||||
this.setupPing()
|
||||
// Send any queued messages after connection is established.
|
||||
this.flushMessageQueue()
|
||||
}
|
||||
|
||||
handleMessage (event) {
|
||||
@@ -61,7 +67,13 @@ export class WebSocketClient {
|
||||
this.convStore.updateConversationMessage(data.data)
|
||||
},
|
||||
[WS_EVENT.MESSAGE_PROP_UPDATE]: () => this.convStore.updateMessageProp(data.data),
|
||||
[WS_EVENT.CONVERSATION_PROP_UPDATE]: () => this.convStore.updateConversationProp(data.data)
|
||||
[WS_EVENT.CONVERSATION_PROP_UPDATE]: () => this.convStore.updateConversationProp(data.data),
|
||||
[WS_EVENT.CONVERSATION_SUBSCRIBED]: () => {
|
||||
console.log('Successfully subscribed to conversation:', data.data.conversation_uuid)
|
||||
},
|
||||
[WS_EVENT.TYPING]: () => {
|
||||
this.convStore.updateTypingStatus(data.data)
|
||||
}
|
||||
}
|
||||
|
||||
const handler = handlers[data.type]
|
||||
@@ -144,10 +156,91 @@ export class WebSocketClient {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message))
|
||||
} else {
|
||||
console.warn('WebSocket is not open. Message not sent:', message)
|
||||
console.warn('WebSocket is not open. Queueing message:', message)
|
||||
this.queueMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
queueMessage (message) {
|
||||
// Don't queue ephemeral message types.
|
||||
if (WS_EPHEMERAL_TYPES.includes(message.type)) {
|
||||
console.log('Skipping queue for ephemeral message type:', message.type)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove expired messages from queue.
|
||||
const now = Date.now()
|
||||
this.messageQueue = this.messageQueue.filter(item =>
|
||||
now - item.timestamp < this.queueTimeoutMs
|
||||
)
|
||||
|
||||
// Remove all existing conversation subscriptions since only one is allowed.
|
||||
if (message.type === WS_EVENT.CONVERSATION_SUBSCRIBE) {
|
||||
this.messageQueue = this.messageQueue.filter(item =>
|
||||
item.type !== WS_EVENT.CONVERSATION_SUBSCRIBE
|
||||
)
|
||||
}
|
||||
|
||||
// Evict oldest message if queue is full.
|
||||
if (this.messageQueue.length >= this.maxQueueSize) {
|
||||
console.warn('Message queue is full, removing oldest message')
|
||||
this.messageQueue.shift()
|
||||
}
|
||||
|
||||
// Push.
|
||||
this.messageQueue.push({
|
||||
...message,
|
||||
timestamp: now
|
||||
})
|
||||
}
|
||||
|
||||
flushMessageQueue () {
|
||||
if (this.messageQueue.length === 0) return
|
||||
|
||||
// Remove expired messages before sending
|
||||
const now = Date.now()
|
||||
this.messageQueue = this.messageQueue.filter(item =>
|
||||
now - item.timestamp < this.queueTimeoutMs
|
||||
)
|
||||
|
||||
if (this.messageQueue.length === 0) return
|
||||
|
||||
console.log(`Sending ${this.messageQueue.length} queued messages`)
|
||||
while (this.messageQueue.length > 0 && this.socket?.readyState === WebSocket.OPEN) {
|
||||
const queuedItem = this.messageQueue.shift()
|
||||
// Remove timestamp before sending
|
||||
delete queuedItem.timestamp
|
||||
this.socket.send(JSON.stringify(queuedItem))
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToConversation (conversationUUID) {
|
||||
if (!conversationUUID) return
|
||||
|
||||
const subscribeMessage = {
|
||||
type: WS_EVENT.CONVERSATION_SUBSCRIBE,
|
||||
data: {
|
||||
conversation_uuid: conversationUUID
|
||||
}
|
||||
}
|
||||
|
||||
this.send(subscribeMessage)
|
||||
}
|
||||
|
||||
sendTypingIndicator (conversationUUID, isTyping) {
|
||||
if (!conversationUUID) return
|
||||
|
||||
const typingMessage = {
|
||||
type: WS_EVENT.TYPING,
|
||||
data: {
|
||||
conversation_uuid: conversationUUID,
|
||||
is_typing: isTyping
|
||||
}
|
||||
}
|
||||
|
||||
this.send(typingMessage)
|
||||
}
|
||||
|
||||
close () {
|
||||
this.manualClose = true
|
||||
this.clearPing()
|
||||
@@ -168,4 +261,6 @@ export function initWS () {
|
||||
}
|
||||
|
||||
export const sendMessage = message => wsClient?.send(message)
|
||||
export const subscribeToConversation = conversationUUID => wsClient?.subscribeToConversation(conversationUUID)
|
||||
export const sendTypingIndicator = (conversationUUID, isTyping) => wsClient?.sendTypingIndicator(conversationUUID, isTyping)
|
||||
export const closeWebSocket = () => wsClient?.close()
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="libredesk-widget-app">
|
||||
<div class="libredesk-widget-app text-foreground bg-background" :class="{ 'dark': widgetStore.config.dark_mode }">
|
||||
<div class="widget-container">
|
||||
<MainLayout />
|
||||
</div>
|
||||
@@ -7,32 +7,75 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, watch, getCurrentInstance } from 'vue'
|
||||
import { useWidgetStore } from './store/widget.js'
|
||||
import api from '@widget/api/index.js'
|
||||
import { useChatStore } from '@widget/store/chat.js'
|
||||
import { useUserStore } from './store/user.js'
|
||||
import { initWidgetWS, closeWidgetWebSocket } from './websocket.js'
|
||||
import { useUnreadCount } from './composables/useUnreadCount.js'
|
||||
import MainLayout from '@widget/layouts/MainLayout.vue'
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchWidgetSettings()
|
||||
// Initialize unread count tracking
|
||||
useUnreadCount()
|
||||
|
||||
onMounted(() => {
|
||||
// Use pre-fetched widget config from main.js
|
||||
const widgetConfig = getCurrentInstance().appContext.config.globalProperties.$widgetConfig
|
||||
if (widgetConfig) {
|
||||
widgetStore.updateConfig(widgetConfig)
|
||||
}
|
||||
|
||||
initializeWebSocket()
|
||||
widgetStore.openWidget()
|
||||
setupParentMessageListeners()
|
||||
chatStore.fetchConversations()
|
||||
})
|
||||
|
||||
// Fetch inbox widget settings from API
|
||||
const fetchWidgetSettings = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inboxID = urlParams.get('inbox_id')
|
||||
if (!inboxID) {
|
||||
throw new Error('`inbox_id` is missing in query parameters')
|
||||
// Listen for messages from parent window (widget.js)
|
||||
const setupParentMessageListeners = () => {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'SET_MOBILE_STATE') {
|
||||
widgetStore.setMobileFullScreen(event.data.isMobile)
|
||||
}
|
||||
const response = await api.getWidgetSettings(inboxID)
|
||||
widgetStore.updateConfig(response.data.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch widget settings:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize WebSocket only when JWT token exists
|
||||
const initializeWebSocket = () => {
|
||||
const jwt = userStore.userSessionToken
|
||||
if (jwt) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inboxId = urlParams.get('inbox_id')
|
||||
if (inboxId) {
|
||||
initWidgetWS(jwt, inboxId)
|
||||
} else {
|
||||
console.error('Cannot initialize WebSocket: missing `inbox_id`')
|
||||
}
|
||||
} else {
|
||||
closeWidgetWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize WebSocket when user gets authenticated
|
||||
const handleUserAuthentication = () => {
|
||||
initializeWebSocket()
|
||||
}
|
||||
|
||||
// Watch for changes in user session token to initialize WebSocket
|
||||
watch(
|
||||
() => userStore.userSessionToken,
|
||||
(newToken) => {
|
||||
if (newToken) {
|
||||
handleUserAuthentication()
|
||||
} else {
|
||||
closeWidgetWebSocket()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import axios from 'axios'
|
||||
|
||||
function getInboxIdFromQuery () {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const inboxId = params.get('inbox_id')
|
||||
return inboxId ? parseInt(inboxId, 10) : null
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
timeout: 10000,
|
||||
responseType: 'json'
|
||||
@@ -14,11 +20,9 @@ http.interceptors.request.use((request) => {
|
||||
// Add libredesk_session to POST/PUT request data
|
||||
if (request.method === 'post' || request.method === 'put') {
|
||||
const libredeskSession = localStorage.getItem('libredesk_session')
|
||||
// Get inbox_id from widget config or use default
|
||||
const inboxId = window.widgetConfig?.inbox_id || 11
|
||||
request.data = {
|
||||
...request.data,
|
||||
inbox_id: inboxId,
|
||||
inbox_id: getInboxIdFromQuery(),
|
||||
jwt: libredeskSession
|
||||
}
|
||||
}
|
||||
@@ -29,6 +33,7 @@ http.interceptors.request.use((request) => {
|
||||
const getWidgetSettings = (inboxID) => http.get('/api/v1/widget/chat/settings', {
|
||||
params: { inbox_id: inboxID }
|
||||
})
|
||||
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
|
||||
const initChatConversation = (data) => http.post('/api/v1/widget/chat/conversations/init', data)
|
||||
const getChatConversations = () => http.post('/api/v1/widget/chat/conversations')
|
||||
const getChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}`)
|
||||
@@ -36,17 +41,18 @@ const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversat
|
||||
const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`)
|
||||
const uploadMedia = (conversationUUID, files) => {
|
||||
const formData = new FormData()
|
||||
|
||||
|
||||
// Add JWT token
|
||||
const libredeskSession = localStorage.getItem('libredesk_session')
|
||||
formData.append('jwt', libredeskSession)
|
||||
formData.append('conversation_uuid', conversationUUID)
|
||||
|
||||
formData.append('inbox_id', getInboxIdFromQuery())
|
||||
|
||||
// Add files
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i])
|
||||
}
|
||||
|
||||
|
||||
return axios.post('/api/v1/widget/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
@@ -55,14 +61,21 @@ const uploadMedia = (conversationUUID, files) => {
|
||||
})
|
||||
}
|
||||
const updateConversationLastSeen = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/update-last-seen`)
|
||||
const submitCSATResponse = (csatUuid, rating, feedback) =>
|
||||
http.post(`/api/v1/csat/${csatUuid}/response`, {
|
||||
rating,
|
||||
feedback,
|
||||
})
|
||||
|
||||
export default {
|
||||
getWidgetSettings,
|
||||
getLanguage,
|
||||
initChatConversation,
|
||||
getChatConversations,
|
||||
getChatConversation,
|
||||
sendChatMessage,
|
||||
closeChatConversation,
|
||||
uploadMedia,
|
||||
updateConversationLastSeen
|
||||
updateConversationLastSeen,
|
||||
submitCSATResponse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="p-4 rounded-2xl text-sm bg-background text-foreground border border-border">
|
||||
<div v-if="!isSubmitted">
|
||||
<p class="mb-3">Please rate this conversation:</p>
|
||||
|
||||
<div class="flex gap-3 mb-4">
|
||||
<button
|
||||
v-for="rating in ratings"
|
||||
:key="rating.value"
|
||||
@click="selectedRating = rating.value"
|
||||
class="flex flex-col items-center p-2 rounded-lg"
|
||||
:class="{ 'scale-150': selectedRating === rating.value }"
|
||||
>
|
||||
<span class="text-xl mb-1">{{ rating.emoji }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ rating.text }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-xs text-muted-foreground mb-2 block">
|
||||
Additional feedback (optional)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="feedback"
|
||||
placeholder="Tell us more..."
|
||||
class="w-full p-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
<div class="text-xs text-muted-foreground text-right mt-1">{{ feedback.length }}/500</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="submitRating"
|
||||
:disabled="(!selectedRating && !feedback.trim()) || isSubmitting"
|
||||
class="w-full py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
|
||||
>
|
||||
<span v-if="isSubmitting">Submitting...</span>
|
||||
<span v-else>Submit feedback</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-2">
|
||||
<p class="mb-3">✅ Thank you for your feedback!</p>
|
||||
|
||||
<!-- Show submitted rating if provided -->
|
||||
<div v-if="csatMeta.submitted_rating" class="mb-2">
|
||||
<span class="text-lg">{{ getRatingEmoji(csatMeta.submitted_rating) }}</span>
|
||||
<span class="text-xs text-muted-foreground ml-2">{{ getRatingText(csatMeta.submitted_rating) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Show submitted feedback if provided -->
|
||||
<div v-if="csatMeta.submitted_feedback" class="text-xs text-muted-foreground italic">
|
||||
"{{ csatMeta.submitted_feedback }}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@widget/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitted'])
|
||||
|
||||
const selectedRating = ref(null)
|
||||
const feedback = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const csatMeta = computed(() => {
|
||||
return props.message.meta
|
||||
})
|
||||
|
||||
const isSubmitted = computed(() => csatMeta.value.csat_submitted === true)
|
||||
const csatUuid = computed(() => csatMeta.value.csat_uuid || '')
|
||||
|
||||
const ratings = [
|
||||
{ value: 1, emoji: '😢', text: 'Poor' },
|
||||
{ value: 2, emoji: '😕', text: 'Fair' },
|
||||
{ value: 3, emoji: '😊', text: 'Good' },
|
||||
{ value: 4, emoji: '😃', text: 'Great' },
|
||||
{ value: 5, emoji: '🤩', text: 'Excellent' }
|
||||
]
|
||||
|
||||
const submitRating = async () => {
|
||||
if ((!selectedRating.value && !feedback.value.trim()) || !csatUuid.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await api.submitCSATResponse(csatUuid.value, selectedRating.value || 0, feedback.value)
|
||||
emit('submitted', {
|
||||
rating: selectedRating.value,
|
||||
feedback: feedback.value,
|
||||
message_uuid: props.message.uuid
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getRatingEmoji = (rating) => {
|
||||
const ratingObj = ratings.find(r => r.value === rating)
|
||||
return ratingObj ? ratingObj.emoji : ''
|
||||
}
|
||||
|
||||
const getRatingText = (rating) => {
|
||||
const ratingObj = ratings.find(r => r.value === rating)
|
||||
return ratingObj ? ratingObj.text : ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center p-2 border-b border-border bg-background gap-3 relative">
|
||||
<div class="flex items-center gap-2 justify-start">
|
||||
<Button @click="$emit('goBack')" variant="ghost" size="sm">
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<ChatTitle />
|
||||
</div>
|
||||
<!-- Close Button - only visible on mobile (full screen mode) -->
|
||||
<CloseWidgetButton class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { ArrowLeft } from 'lucide-vue-next'
|
||||
import ChatTitle from './ChatTitle.vue'
|
||||
import CloseWidgetButton from './CloseWidgetButton.vue'
|
||||
|
||||
defineEmits(['goBack'])
|
||||
</script>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="flex flex-col relative flex-1 min-h-0">
|
||||
<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"
|
||||
ref="messagesContainer"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<!-- Chat Intro -->
|
||||
<ChatIntro :introText="config.chat_introduction" />
|
||||
|
||||
<!-- Notice -->
|
||||
<NoticeBanner
|
||||
v-if="config.notice_banner.enabled === true"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<!-- Regular Message Bubble -->
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'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 rounded-br-sm',
|
||||
message.status === 'sending'
|
||||
? 'bg-primary/60'
|
||||
: message.status === 'failed'
|
||||
? 'bg-destructive/60'
|
||||
: 'bg-primary'
|
||||
]
|
||||
: 'bg-background text-foreground rounded-bl-sm border border-border'
|
||||
]"
|
||||
>
|
||||
<!-- Message content rendered using vue-letter -->
|
||||
<Letter
|
||||
:html="message.content"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html"
|
||||
/>
|
||||
<!-- Show attachments if available -->
|
||||
<MessageAttachment :attachments="message.attachments" />
|
||||
</div>
|
||||
|
||||
<!-- Message metadata -->
|
||||
<div class="text-xs 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'" class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
Sending...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ 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-background text-foreground rounded-bl-sm border border-border"
|
||||
>
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky scroll to bottom button -->
|
||||
<ScrollToBottomButton
|
||||
:is-at-bottom="isAtBottom"
|
||||
:unread-count="unreadMessages"
|
||||
@scroll-to-bottom="handleScrollToBottom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch } from 'vue'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { useRelativeTime } from '@widget/composables/useRelativeTime.js'
|
||||
import { Letter } from 'vue-letter'
|
||||
import ScrollToBottomButton from '@shared-ui/components/ScrollToBottomButton'
|
||||
import ChatIntro from './ChatIntro.vue'
|
||||
import NoticeBanner from './NoticeBanner.vue'
|
||||
import MessageAttachment from './MessageAttachment.vue'
|
||||
import CSATMessageBubble from './CSATMessageBubble.vue'
|
||||
import { TypingIndicator } from '@shared-ui/components/TypingIndicator'
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const messagesContainer = ref(null)
|
||||
const isAtBottom = ref(true)
|
||||
const unreadMessages = ref(0)
|
||||
const currentConversationUUID = ref('')
|
||||
|
||||
const config = computed(() => widgetStore.config)
|
||||
const isTyping = computed(() => chatStore.isTyping)
|
||||
|
||||
const getMessageTime = (timestamp) => {
|
||||
return useRelativeTime(new Date(timestamp)).value
|
||||
}
|
||||
|
||||
// handleCSATSubmitted updates the local message state when CSAT feedback is submitted.
|
||||
const handleCSATSubmitted = ({ message_uuid, rating, feedback }) => {
|
||||
const currentMessage = chatStore.getCurrentConversationMessages.find(
|
||||
(m) => m.uuid === message_uuid
|
||||
)
|
||||
const updatedMeta = {
|
||||
...currentMessage.meta,
|
||||
csat_submitted: true,
|
||||
is_csat: true
|
||||
}
|
||||
|
||||
// Add submitted rating and feedback to meta if provided
|
||||
if (rating > 0) {
|
||||
updatedMeta.submitted_rating = rating
|
||||
}
|
||||
if (feedback && feedback.trim()) {
|
||||
updatedMeta.submitted_feedback = feedback.trim()
|
||||
}
|
||||
|
||||
chatStore.replaceMessage(chatStore.currentConversation.uuid, message_uuid, {
|
||||
...currentMessage,
|
||||
meta: updatedMeta
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
checkIfAtBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Check initial scroll position
|
||||
checkIfAtBottom()
|
||||
|
||||
// Scroll to bottom on mount
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 200)
|
||||
|
||||
// Update conversation last seen timestamp.
|
||||
chatStore.updateCurrentConversationLastSeen()
|
||||
})
|
||||
|
||||
// Only auto-scroll for user's own messages or when at bottom
|
||||
watch(
|
||||
() => chatStore.getCurrentConversationMessages,
|
||||
(newMessages, oldMessages) => {
|
||||
if (!newMessages || newMessages.length === 0) return
|
||||
|
||||
// Check if this is a new conversation
|
||||
const currentConvUUID = chatStore.currentConversation?.uuid
|
||||
if (currentConvUUID && currentConversationUUID.value !== currentConvUUID) {
|
||||
currentConversationUUID.value = currentConvUUID
|
||||
unreadMessages.value = 0
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if new messages were added
|
||||
if (oldMessages && newMessages.length > oldMessages.length) {
|
||||
const newMessage = newMessages[newMessages.length - 1]
|
||||
|
||||
// Auto-scroll if:
|
||||
// 1. Message is from current user (contact/visitor), OR
|
||||
// 2. User is already at the bottom
|
||||
if (
|
||||
newMessage.author?.type === 'contact' ||
|
||||
newMessage.author?.type === 'visitor' ||
|
||||
isAtBottom.value
|
||||
) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
// User is scrolled up and agent sent message - show unread count
|
||||
unreadMessages.value++
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Watch for typing indicator and auto-scroll if user is at bottom
|
||||
watch(
|
||||
() => chatStore.isTyping,
|
||||
(isTyping) => {
|
||||
if (isTyping && isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Avatar class="size-10">
|
||||
<AvatarImage :src="chatTitle.avatarUrl" />
|
||||
<AvatarFallback>{{ chatTitle.avatarFallback }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-base font-bold text-foreground">
|
||||
{{ chatTitle.name }}
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<!-- Show business hours status meaning we are out of business hours -->
|
||||
<span v-if="businessHoursStatus">
|
||||
{{ businessHoursStatus }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
chatTitle.availability_status === 'online' || chatTitle.availability_status === 'away'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full mr-1"
|
||||
:class="{
|
||||
'bg-green-500': chatTitle.availability_status === 'online',
|
||||
'bg-amber-500': chatTitle.availability_status === 'away'
|
||||
}"
|
||||
></span>
|
||||
{{
|
||||
chatTitle.availability_status.charAt(0).toUpperCase() +
|
||||
chatTitle.availability_status.slice(1)
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="chatStore.currentConversation?.assignee?.active_at">
|
||||
Active
|
||||
{{ getRelativeTime(chatStore.currentConversation?.assignee?.active_at).toLowerCase() }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useChatStore } from '@widget/store/chat.js'
|
||||
import { useWidgetStore } from '@widget/store/widget.js'
|
||||
import { computed } from 'vue'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
import { useBusinessHours } from '@widget/composables/useBusinessHours.js'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const { resolveBusinessHours, getBusinessHoursStatus } = useBusinessHours()
|
||||
|
||||
const businessHoursStatus = computed(() => {
|
||||
const config = widgetStore.config
|
||||
|
||||
// Show business hrs?
|
||||
if (!config.show_office_hours_in_chat) {
|
||||
return null
|
||||
}
|
||||
|
||||
const conversation = chatStore.currentConversation
|
||||
if (!conversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const businessHours = resolveBusinessHours({
|
||||
showOfficeHours: config.show_office_hours_in_chat,
|
||||
showAfterAssignment: config.show_office_hours_after_assignment,
|
||||
assignedBusinessHoursId: conversation.business_hours_id,
|
||||
defaultBusinessHoursId: config.default_business_hours_id,
|
||||
businessHoursList: config.business_hours
|
||||
})
|
||||
|
||||
if (!businessHours) {
|
||||
return null
|
||||
}
|
||||
|
||||
const utcOffset = conversation.working_hours_utc_offset || 0
|
||||
const withinHoursMessage = config.chat_reply_expectation_message || ''
|
||||
|
||||
const { status, isWithin } = getBusinessHoursStatus(businessHours, utcOffset, withinHoursMessage)
|
||||
return isWithin ? null : status
|
||||
})
|
||||
|
||||
const chatTitle = computed(() => {
|
||||
const config = widgetStore.config
|
||||
const assignee = chatStore.currentConversation?.assignee
|
||||
if (assignee?.id && assignee?.id > 0) {
|
||||
return {
|
||||
name: assignee.first_name,
|
||||
avatarUrl: assignee.avatar_url || '',
|
||||
avatarFallback: assignee.first_name.charAt(0).toUpperCase(),
|
||||
availability_status: assignee.availability_status,
|
||||
hasAssignee: true
|
||||
}
|
||||
}
|
||||
// Default brand values
|
||||
return {
|
||||
name: config.brand_name,
|
||||
avatarUrl: config.launcher?.logo_url || '',
|
||||
avatarFallback: config.brand_name.charAt(0).toUpperCase(),
|
||||
availability_status: null,
|
||||
hasAssignee: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="widgetStore.isMobileFullScreen"
|
||||
@click="closeWidget"
|
||||
variant="ghost"
|
||||
aria-label="Close chat"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useWidgetStore } from '@widget/store/widget.js'
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
// Send message to parent window (widget.js) to close the widget
|
||||
const closeWidget = () => {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'CLOSE_WIDGET'
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div v-if="config.external_links?.length" class="space-y-2">
|
||||
<a
|
||||
v-for="link in config.external_links"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
class="block no-underline"
|
||||
>
|
||||
<Card class="hover:bg-accent transition-colors cursor-pointer rounded-md">
|
||||
<CardContent class="p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-primary font-medium">
|
||||
{{ link.text }}
|
||||
</span>
|
||||
<ExternalLink size="18" class="text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Card, CardContent } from '@shared-ui/components/ui/card'
|
||||
import { ExternalLink } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- Logo -->
|
||||
<img
|
||||
v-if="config.logo_url"
|
||||
:src="config.logo_url"
|
||||
:alt="config.brand_name"
|
||||
class="max-h-8 max-w-full"
|
||||
/>
|
||||
<!-- Greeting and introduction -->
|
||||
<div class="mt-24 font-medium text-4xl">
|
||||
<h2>{{ config.greeting_message }}</h2>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{{ config.introduction_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<div class="flex flex-wrap gap-2" v-if="attachments && attachments.length > 0">
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.uuid"
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="border-t focus:ring-0 focus:outline-none">
|
||||
<div class="p-2">
|
||||
<!-- Unified Input Container -->
|
||||
<div class="border border-input rounded-lg bg-background focus-within:border-primary">
|
||||
<!-- Textarea Container -->
|
||||
<div class="p-2">
|
||||
<Textarea
|
||||
v-model="newMessage"
|
||||
@keydown="handleKeydown"
|
||||
@input="handleTyping"
|
||||
placeholder="Type your message..."
|
||||
class="w-full min-h-6 max-h-32 resize-none border-0 bg-transparent focus:ring-0 focus:outline-none focus-visible:ring-0 p-0 shadow-none"
|
||||
ref="messageInput"
|
||||
></Textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions and Send Button -->
|
||||
<div class="flex justify-between items-center px-3 pb-2">
|
||||
<!-- Message Input Actions (file upload + emoji) -->
|
||||
<MessageInputActions
|
||||
:fileUploadEnabled="config.features?.file_upload || false"
|
||||
:emojiEnabled="config.features?.emoji || false"
|
||||
:uploading="isUploading"
|
||||
@fileUpload="handleFileUpload"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
|
||||
<!-- Send Button -->
|
||||
<Button
|
||||
@click="sendMessage"
|
||||
size="sm"
|
||||
class="h-8 px-2 rounded-full disabled:opacity-50 disabled:cursor-not-allowed border-0"
|
||||
:disabled="!newMessage.trim() || isUploading"
|
||||
>
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ArrowUp } from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Textarea } from '@shared-ui/components/ui/textarea'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { useUserStore } from '@widget/store/user.js'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { sendWidgetTyping } from '../websocket.js'
|
||||
import { convertTextToHtml } from '@shared-ui/utils/string.js'
|
||||
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
|
||||
import MessageInputActions from './MessageInputActions.vue'
|
||||
import api from '@widget/api/index.js'
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits(['error'])
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
const messageInput = ref(null)
|
||||
const newMessage = ref('')
|
||||
const isUploading = ref(false)
|
||||
const config = computed(() => widgetStore.config)
|
||||
|
||||
// Setup typing indicator
|
||||
const { startTyping, stopTyping } = useTypingIndicator((isTyping) => {
|
||||
if (chatStore.currentConversation?.uuid) {
|
||||
sendWidgetTyping(isTyping, chatStore.currentConversation.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
const initChatConversation = async (messageText) => {
|
||||
const resp = await api.initChatConversation({
|
||||
message: messageText
|
||||
})
|
||||
const { conversation, jwt, messages } = resp.data.data
|
||||
|
||||
// Set user session token if not already set.
|
||||
if (!userStore.userSessionToken) {
|
||||
userStore.setSessionToken(jwt)
|
||||
}
|
||||
|
||||
// Update chat store with new conversation and messages.
|
||||
chatStore.setCurrentConversation(conversation)
|
||||
chatStore.replaceMessages(messages)
|
||||
}
|
||||
|
||||
const sendMessageToConversation = async (messageText) => {
|
||||
// Add pending message immediately for existing conversation
|
||||
const tempMessageID = chatStore.addPendingMessage(
|
||||
chatStore.currentConversation.uuid,
|
||||
messageText,
|
||||
userStore.isVisitor ? 'visitor' : 'contact',
|
||||
userStore.userID
|
||||
)
|
||||
|
||||
// Send message in existing conversation.
|
||||
const messageResp = await api.sendChatMessage(chatStore.currentConversation.uuid, {
|
||||
message: messageText
|
||||
})
|
||||
|
||||
// Update the pending message with the actual message.
|
||||
if (tempMessageID && messageResp.data.data) {
|
||||
chatStore.replaceMessage(
|
||||
chatStore.currentConversation.uuid,
|
||||
tempMessageID,
|
||||
messageResp.data.data
|
||||
)
|
||||
}
|
||||
if (messageResp.data.data) {
|
||||
chatStore.updateConversationListLastMessage(
|
||||
chatStore.currentConversation.uuid,
|
||||
messageResp.data.data
|
||||
)
|
||||
}
|
||||
|
||||
return tempMessageID
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
// Empty?
|
||||
if (!newMessage.value.trim()) return
|
||||
|
||||
// Stop typing when sending message
|
||||
stopTyping()
|
||||
|
||||
// Convert text to HTML.
|
||||
const messageText = convertTextToHtml(newMessage.value.trim())
|
||||
|
||||
// Clear input field immediately
|
||||
newMessage.value = ''
|
||||
|
||||
// Temporary message ID for pending messages.
|
||||
let tempMessageID = null
|
||||
try {
|
||||
// No current conversation ID? Start a new conversation.
|
||||
if (!chatStore.currentConversation.uuid) {
|
||||
await initChatConversation(messageText)
|
||||
} else {
|
||||
tempMessageID = await sendMessageToConversation(messageText)
|
||||
}
|
||||
emit('error', '')
|
||||
} catch (error) {
|
||||
// Remove failed message if we have a temp ID.
|
||||
if (tempMessageID) {
|
||||
chatStore.removeMessage(chatStore.currentConversation.uuid, tempMessageID)
|
||||
}
|
||||
|
||||
// Unauthorized?
|
||||
if (error.response && error.response.status === 401) {
|
||||
userStore.clearSessionToken()
|
||||
chatStore.setCurrentConversation(null)
|
||||
widgetStore.closeWidget()
|
||||
}
|
||||
emit('error', handleHTTPError(error).message)
|
||||
} finally {
|
||||
await chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle typing events
|
||||
const handleTyping = () => {
|
||||
startTyping()
|
||||
}
|
||||
|
||||
// Handle Enter vs Shift+Enter for new lines
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// File upload handler
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!chatStore.currentConversation.uuid || files.length === 0) return
|
||||
|
||||
isUploading.value = true
|
||||
emit('error', '')
|
||||
try {
|
||||
// Upload files using the widget API
|
||||
await api.uploadMedia(chatStore.currentConversation.uuid, files)
|
||||
|
||||
// Refresh conversation to get updated messages with attachments
|
||||
const resp = await api.getChatConversation(chatStore.currentConversation.uuid)
|
||||
const msgs = resp.data.data.messages
|
||||
chatStore.replaceMessages(msgs)
|
||||
} catch (error) {
|
||||
emit('error', handleHTTPError(error).message)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle emoji selection.
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
const textarea = messageInput.value?.$el?.querySelector?.('textarea') || messageInput.value?.$el
|
||||
if (textarea && textarea.selectionStart !== undefined) {
|
||||
// Insert emoji at cursor position
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const before = newMessage.value.substring(0, start)
|
||||
const after = newMessage.value.substring(end)
|
||||
|
||||
newMessage.value = before + emoji + after
|
||||
|
||||
// Restore cursor position after emoji
|
||||
nextTick(() => {
|
||||
const newPos = start + emoji.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
})
|
||||
} else {
|
||||
// Fallback: append emoji
|
||||
newMessage.value += emoji
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize textarea on input.
|
||||
watch(newMessage, () => {
|
||||
nextTick(() => {
|
||||
if (messageInput.value?.$el) {
|
||||
const textarea = messageInput.value.$el
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 128) + 'px'
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingConversations" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!hasConversations" class="flex flex-col items-center justify-center py-12 px-4">
|
||||
<MessageCircleDashed class="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<h4 class="text-sm text-muted-foreground mb-2">No messages yet</h4>
|
||||
</div>
|
||||
|
||||
<!-- List of conversations -->
|
||||
<div v-else class="divide-y divide-border">
|
||||
<div
|
||||
v-for="conversation in getConversations"
|
||||
:key="conversation.uuid"
|
||||
@click="setCurrentConversation(conversation.uuid)"
|
||||
class="p-4 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-foreground text-sm truncate mb-1">
|
||||
{{ conversation.last_message.content }}
|
||||
</h4>
|
||||
<div
|
||||
class="text-xs text-muted-foreground flex items-center gap-1"
|
||||
v-if="conversation.last_message && conversation.last_message.author"
|
||||
>
|
||||
<span v-if="conversation.last_message?.author?.id !== userStore.userID">
|
||||
{{
|
||||
conversation.last_message?.author.first_name +
|
||||
' ' +
|
||||
conversation.last_message?.author.last_name
|
||||
}}
|
||||
</span>
|
||||
<span v-else>You</span>
|
||||
<span>•</span>
|
||||
<span>{{ getConversationTime(conversation.last_message.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center flex-shrink-0 gap-2">
|
||||
<UnreadCountBadge :count="conversation.unread_message_count" />
|
||||
<ArrowRight class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { MessageCircleDashed, ArrowRight } from 'lucide-vue-next'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useUserStore } from '@widget/store/user.js'
|
||||
import { useRelativeTime } from '@widget/composables/useRelativeTime.js'
|
||||
import UnreadCountBadge from './UnreadCountBadge.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const hasConversations = computed(() => chatStore.hasConversations)
|
||||
const getConversations = computed(() => chatStore.getConversations)
|
||||
const isLoadingConversations = computed(() => chatStore.isLoadingConversations)
|
||||
|
||||
const getConversationTime = (timestamp) => {
|
||||
return useRelativeTime(new Date(timestamp)).value
|
||||
}
|
||||
|
||||
const setCurrentConversation = async (conversationUUID) => {
|
||||
// Navigate to chat view
|
||||
widgetStore.navigateToChat()
|
||||
// Fetch conversation and messages and set it as current conversation.
|
||||
const fetched = await chatStore.loadConversation(conversationUUID)
|
||||
// If fetch fails, navigate back to message list.
|
||||
if (!fetched) {
|
||||
widgetStore.navigateToMessages()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.fetchConversations()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<Card
|
||||
@click="continueConversation"
|
||||
class="hover:bg-accent transition-colors cursor-pointer rounded-md"
|
||||
>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="text-sm font-medium">Continue your conversation</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="text-sm text-foreground line-clamp-2 flex-1 min-w-0">
|
||||
{{ conversation.last_message.content }}
|
||||
</div>
|
||||
<UnreadCountBadge :count="conversation.unread_message_count" class="flex-shrink-0" />
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
<span>{{ authorDisplayName }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
<span>{{ getRelativeTime(new Date(conversation.last_message.created_at)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-muted-foreground ml-2 flex-shrink-0 self-center" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
import { Card, CardContent } from '@shared-ui/components/ui/card'
|
||||
import UnreadCountBadge from '@widget/components/UnreadCountBadge.vue'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
import { useChatStore } from '@widget/store/chat.js'
|
||||
import { useWidgetStore } from '@widget/store/widget.js'
|
||||
import { useUserStore } from '@widget/store/user.js'
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const authorDisplayName = computed(() => {
|
||||
const author = props.conversation.last_message.author
|
||||
if (!author) return 'Someone'
|
||||
// Check if the author is the current user
|
||||
if (author.id === userStore.userID) {
|
||||
return 'You'
|
||||
}
|
||||
return author.first_name || 'Someone'
|
||||
})
|
||||
|
||||
const continueConversation = async () => {
|
||||
// Load the conversation details and messages
|
||||
await chatStore.loadConversation(props.conversation.uuid)
|
||||
// Navigate to chat view
|
||||
widgetStore.navigateToChat()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-primary text-primary-foreground text-xs rounded-full min-w-[1.2rem] h-5 flex items-center justify-center font-bold"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="errorMessage" class="p-4 bg-destructive/10 border-t border-destructive/20 flex-shrink-0">
|
||||
<div v-if="errorMessage" class="p-3 bg-destructive/10 border-t border-destructive/20 flex-shrink-0">
|
||||
<p class="text-sm text-destructive text-center">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { format, isToday, isTomorrow, addDays, setHours, setMinutes } from 'date-fns'
|
||||
|
||||
/**
|
||||
* Business hours composable providing generic business hours utilities.
|
||||
*/
|
||||
export function useBusinessHours() {
|
||||
|
||||
/**
|
||||
* Get the business hours by ID from a list
|
||||
* @param {number} businessHoursId - Business hours ID
|
||||
* @param {Array} businessHoursList - List of business hours objects
|
||||
* @returns {Object|null} Business hours object or null
|
||||
*/
|
||||
function getBusinessHoursById(businessHoursId, businessHoursList) {
|
||||
if (!businessHoursId || !businessHoursList) {
|
||||
return null
|
||||
}
|
||||
|
||||
return businessHoursList.find(bh => bh.id === businessHoursId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which business hours to use based on configuration
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.showOfficeHours - Whether to show office hours
|
||||
* @param {boolean} options.showAfterAssignment - Whether to show team hours after assignment
|
||||
* @param {number|null} options.assignedBusinessHoursId - Business hours ID from assignment
|
||||
* @param {number|null} options.defaultBusinessHoursId - Default business hours ID
|
||||
* @param {Array} options.businessHoursList - List of available business hours
|
||||
* @returns {Object|null} Business hours object or null
|
||||
*/
|
||||
function resolveBusinessHours(options) {
|
||||
const {
|
||||
showOfficeHours,
|
||||
showAfterAssignment,
|
||||
assignedBusinessHoursId,
|
||||
defaultBusinessHoursId,
|
||||
businessHoursList
|
||||
} = options
|
||||
|
||||
if (!showOfficeHours) {
|
||||
return null
|
||||
}
|
||||
|
||||
let businessHoursId = null
|
||||
|
||||
// Check if we should use assigned business hours
|
||||
if (showAfterAssignment && assignedBusinessHoursId) {
|
||||
businessHoursId = assignedBusinessHoursId
|
||||
} else if (defaultBusinessHoursId) {
|
||||
// Fallback to default business hours
|
||||
businessHoursId = parseInt(defaultBusinessHoursId)
|
||||
}
|
||||
|
||||
return getBusinessHoursById(businessHoursId, businessHoursList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given time is within business hours
|
||||
* @param {Object} businessHours - Business hours object
|
||||
* @param {Date} date - Date to check
|
||||
* @param {number} utcOffset - UTC offset in minutes
|
||||
* @returns {boolean} True if within business hours
|
||||
*/
|
||||
function isWithinBusinessHours(businessHours, date, utcOffset = 0) {
|
||||
if (!businessHours || businessHours.is_always_open) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Convert to business timezone
|
||||
const localDate = new Date(date.getTime() + (utcOffset * 60000))
|
||||
|
||||
// Check if it's a holiday
|
||||
if (isHoliday(businessHours, localDate)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dayName = getDayName(localDate.getDay())
|
||||
const schedule = businessHours.hours[dayName]
|
||||
|
||||
if (!schedule || !schedule.open || !schedule.close) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if open and close times are the same (closed day)
|
||||
if (schedule.open === schedule.close) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTime = format(localDate, 'HH:mm')
|
||||
return currentTime >= schedule.open && currentTime <= schedule.close
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is a holiday
|
||||
* @param {Object} businessHours - Business hours object
|
||||
* @param {Date} date - Date to check
|
||||
* @returns {boolean} True if it's a holiday
|
||||
*/
|
||||
function isHoliday(businessHours, date) {
|
||||
if (!businessHours.holidays || businessHours.holidays.length === 0) {
|
||||
return false
|
||||
}
|
||||
const dateStr = format(date, 'yyyy-MM-dd')
|
||||
return businessHours.holidays.some(holiday => holiday.date === dateStr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next working time
|
||||
* @param {Object} businessHours - Business hours object
|
||||
* @param {Date} fromDate - Date to start from
|
||||
* @param {number} utcOffset - UTC offset in minutes
|
||||
* @returns {Date|null} Next working time or null
|
||||
*/
|
||||
function getNextWorkingTime(businessHours, fromDate, utcOffset = 0) {
|
||||
if (!businessHours || businessHours.is_always_open) {
|
||||
return fromDate
|
||||
}
|
||||
|
||||
// Check up to 14 days ahead
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const checkDate = addDays(fromDate, i)
|
||||
const localDate = new Date(checkDate.getTime() + (utcOffset * 60000))
|
||||
|
||||
// Skip holidays
|
||||
if (isHoliday(businessHours, localDate)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dayName = getDayName(localDate.getDay())
|
||||
const schedule = businessHours.hours[dayName]
|
||||
|
||||
if (!schedule || !schedule.open || !schedule.close || schedule.open === schedule.close) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse opening time
|
||||
const [openHour, openMinute] = schedule.open.split(':').map(Number)
|
||||
let nextWorking = setMinutes(setHours(localDate, openHour), openMinute)
|
||||
|
||||
// If it's the same day and current time is before opening time
|
||||
if (i === 0) {
|
||||
const currentTime = format(localDate, 'HH:mm')
|
||||
if (currentTime < schedule.open) {
|
||||
// Convert back from business timezone to user timezone
|
||||
return new Date(nextWorking.getTime() - (utcOffset * 60000))
|
||||
}
|
||||
// If it's the same day but past opening time, continue to next day
|
||||
continue
|
||||
}
|
||||
|
||||
// For future days, return the opening time
|
||||
// Convert back from business timezone to user timezone
|
||||
return new Date(nextWorking.getTime() - (utcOffset * 60000))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day name from day number
|
||||
* @param {number} dayNum - Day number (0 = Sunday, 1 = Monday, etc.)
|
||||
* @returns {string} Day name
|
||||
*/
|
||||
function getDayName(dayNum) {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
return days[dayNum]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the next working time for display
|
||||
* @param {Date} nextWorkingTime - Next working time
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatNextWorkingTime(nextWorkingTime) {
|
||||
if (!nextWorkingTime) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isToday(nextWorkingTime)) {
|
||||
return `today at ${format(nextWorkingTime, 'h:mm a')}`
|
||||
} else if (isTomorrow(nextWorkingTime)) {
|
||||
return `tomorrow at ${format(nextWorkingTime, 'h:mm a')}`
|
||||
} else {
|
||||
return `on ${format(nextWorkingTime, 'EEEE')} at ${format(nextWorkingTime, 'h:mm a')}`
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get business hours status message and whether it's within business hours
|
||||
* @param {Object} businessHours - Business hours object
|
||||
* @param {number} utcOffset - UTC offset in minutes
|
||||
* @param {string} withinHoursMessage - Message to show when within hours
|
||||
* @returns {Object|null} { status: string|null, isWithin: boolean } or null
|
||||
*/
|
||||
function getBusinessHoursStatus(businessHours, utcOffset = 0, withinHoursMessage = '') {
|
||||
if (!businessHours) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const within = isWithinBusinessHours(businessHours, now, utcOffset)
|
||||
|
||||
let status = null
|
||||
if (within) {
|
||||
status = withinHoursMessage
|
||||
} else {
|
||||
const nextWorkingTime = getNextWorkingTime(businessHours, now, utcOffset)
|
||||
if (nextWorkingTime) {
|
||||
status = `We'll be back ${formatNextWorkingTime(nextWorkingTime)}`
|
||||
} else {
|
||||
status = 'We are currently offline'
|
||||
}
|
||||
}
|
||||
|
||||
return { status, isWithin: within }
|
||||
}
|
||||
|
||||
return {
|
||||
getBusinessHoursById,
|
||||
resolveBusinessHours,
|
||||
isWithinBusinessHours,
|
||||
getNextWorkingTime,
|
||||
formatNextWorkingTime,
|
||||
getBusinessHoursStatus,
|
||||
isHoliday,
|
||||
getDayName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
|
||||
export function useRelativeTime (timestamp) {
|
||||
const relativeTime = ref(getRelativeTime(timestamp))
|
||||
|
||||
useIntervalFn(() => {
|
||||
relativeTime.value = getRelativeTime(timestamp)
|
||||
}, 60000)
|
||||
|
||||
return relativeTime
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { useChatStore } from '@widget/store/chat.js'
|
||||
|
||||
export function useUnreadCount() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// Calculate total unread messages across all conversations.
|
||||
const totalUnreadCount = computed(() => {
|
||||
const conversations = chatStore.getConversations
|
||||
if (!conversations || conversations.length === 0) return 0
|
||||
|
||||
return conversations.reduce((total, conversation) => {
|
||||
return total + (conversation.unread_message_count || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Send unread count to parent widget.
|
||||
const sendUnreadCountToWidget = (count) => {
|
||||
try {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'UPDATE_UNREAD_COUNT',
|
||||
count: count
|
||||
}, '*')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send unread count to widget:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in unread count and notify the widget.
|
||||
watch(totalUnreadCount, (newCount) => {
|
||||
sendUnreadCountToWidget(newCount)
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
totalUnreadCount,
|
||||
sendUnreadCountToWidget
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,17 @@
|
||||
<ChatView v-else />
|
||||
</TabsContent>
|
||||
</div>
|
||||
<TabsList class="grid grid-cols-2 border-t w-full h-10 bg-background">
|
||||
<TabsTrigger value="home" class="flex-col gap-1 px-4 !shadow-none">
|
||||
<TabsList class="grid grid-cols-2 border-t rounded-none">
|
||||
<TabsTrigger value="home" class="flex gap-1">
|
||||
<Home class="w-5 h-5" />
|
||||
<span class="text-xs">Home</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="messages" class="flex-col gap-1 px-4 !shadow-none">
|
||||
<TabsTrigger value="messages" class="flex gap-1">
|
||||
<MessageCircle class="w-5 h-5" />
|
||||
<span class="text-xs">Messages</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div class="py-2 text-center">
|
||||
<div class="text-center flex items-center justify-center py-1">
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>Powered by <a href="https://libredesk.io" target="_blank">Libredesk</a></span
|
||||
>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center p-4 border-b relative">
|
||||
<h3 class="text-base font-semibold text-foreground">{{ title }}</h3>
|
||||
<div class="absolute right-4">
|
||||
<CloseWidgetButton />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import CloseWidgetButton from '@widget/components/CloseWidgetButton.vue'
|
||||
|
||||
defineProps(['title'])
|
||||
</script>
|
||||
@@ -1,10 +1,57 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import App from './App.vue'
|
||||
import api from './api/index.js'
|
||||
import '@shared-ui/assets/styles/main.scss'
|
||||
import './assets/widget.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.mount('#app')
|
||||
async function initWidget () {
|
||||
try {
|
||||
// Get `inbox_id` from URL params
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inboxID = urlParams.get('inbox_id')
|
||||
|
||||
if (!inboxID) {
|
||||
throw new Error('`inbox_id` is missing in query parameters')
|
||||
}
|
||||
|
||||
// Fetch widget settings to get language config
|
||||
const widgetSettingsResponse = await api.getWidgetSettings(inboxID)
|
||||
const widgetConfig = widgetSettingsResponse.data.data
|
||||
|
||||
// Get language from config or default to 'en'
|
||||
const lang = widgetConfig.language || 'en'
|
||||
|
||||
// Fetch language messages
|
||||
const langMessages = await api.getLanguage(lang)
|
||||
|
||||
// Initialize i18n
|
||||
const i18nConfig = {
|
||||
legacy: false,
|
||||
locale: lang,
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
[lang]: langMessages.data
|
||||
}
|
||||
}
|
||||
|
||||
const i18n = createI18n(i18nConfig)
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
// Store widget config globally for access in App.vue
|
||||
app.config.globalProperties.$widgetConfig = widgetConfig
|
||||
app.mount('#app')
|
||||
} catch (error) {
|
||||
console.error('Error initializing widget:', error)
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.mount('#app')
|
||||
}
|
||||
}
|
||||
|
||||
initWidget()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { initWidgetWS } from '../websocket.js'
|
||||
import api from '../api/index.js'
|
||||
import MessageCache from '@main/utils/conversation-message-cache.js'
|
||||
import { useUserStore } from './user.js'
|
||||
@@ -14,75 +13,121 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// Conversation messages cache, evict old conversation messages after 50 conversations.
|
||||
const messageCache = reactive(new MessageCache(50))
|
||||
const isLoadingConversations = ref(false)
|
||||
// Reactivity trigger for message cache changes this is easier than making the whole messageCache reactive.
|
||||
const messageCacheVersion = ref(0)
|
||||
|
||||
// Getters
|
||||
const getCurrentConversationMessages = () => {
|
||||
const getCurrentConversationMessages = computed(() => {
|
||||
messageCacheVersion.value // Force reactivity tracking
|
||||
const convId = currentConversation.value?.uuid
|
||||
if (!convId) return []
|
||||
return messageCache.getAllPagesMessages(convId)
|
||||
}
|
||||
})
|
||||
const hasConversations = computed(() => conversations.value?.length > 0)
|
||||
const getConversations = computed(() => {
|
||||
// Sort by `last_message_at` descending.
|
||||
// Sort by `last_message.created_at` descending.
|
||||
if (conversations.value) {
|
||||
return conversations.value.sort((a, b) => new Date(b.last_message_at) - new Date(a.last_message_at))
|
||||
return conversations.value.sort((a, b) => new Date(b.last_message.created_at) - new Date(a.last_message.created_at))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Actions
|
||||
const updateConversationListLastMessage = (conversationUUID, message, incrementUnread = false) => {
|
||||
if (!conversations.value || !Array.isArray(conversations.value)) return
|
||||
|
||||
// Find conversation in the list
|
||||
const conv = conversations.value.find(c => c.uuid === conversationUUID)
|
||||
if (!conv) return
|
||||
|
||||
// Update last_message in the conversation
|
||||
conv.last_message = {
|
||||
content: message.text_content !== '' ? message.text_content : message.content,
|
||||
created_at: message.created_at,
|
||||
status: message.status,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
first_name: message.author.first_name || '',
|
||||
last_name: message.author.last_name || '',
|
||||
avatar_url: message.author.avatar_url || '',
|
||||
availability_status: message.author.availability_status || '',
|
||||
type: message.author.type || '',
|
||||
active_at: message.author.active_at || null
|
||||
}
|
||||
}
|
||||
|
||||
// Increment unread count if needed
|
||||
if (incrementUnread) {
|
||||
conv.unread_message_count = (conv.unread_message_count || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
const addMessageToConversation = (conversationUUID, message) => {
|
||||
messageCache.addMessage(conversationUUID, message)
|
||||
// Update `last_message` for the conversations list.
|
||||
const conv = conversations.value.find(c => c.uuid === conversationUUID)
|
||||
if (conv) {
|
||||
conv.last_message = message.text_content
|
||||
conv.last_message_at = message.created_at
|
||||
}
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
// Check if we should increment unread count (message from other user)
|
||||
const shouldIncrementUnread = message.author.id !== userStore.userID
|
||||
updateConversationListLastMessage(conversationUUID, message, shouldIncrementUnread)
|
||||
}
|
||||
|
||||
const fetchCurrentConversation = async () => {
|
||||
const conversationUUID = currentConversation.value?.uuid
|
||||
if (!conversationUUID) return
|
||||
|
||||
// Set unread message count to 0 for the current conversation
|
||||
const conv = conversations.value.find(c => c.uuid === conversationUUID)
|
||||
if (conv) {
|
||||
conv.unread_message_count = 0
|
||||
const addPendingMessage = (conversationUUID, messageText, authorType, authorId) => {
|
||||
// Pending message is a temporary message that will be replaced with actual message later after sending.
|
||||
const pendingMessage = {
|
||||
content: messageText,
|
||||
author: {
|
||||
type: authorType,
|
||||
id: authorId,
|
||||
first_name: userStore.firstName || '',
|
||||
last_name: userStore.lastName || '',
|
||||
avatar_url: userStore.avatarUrl || '',
|
||||
availability_status: '',
|
||||
active_at: null
|
||||
},
|
||||
attachments: [],
|
||||
uuid: `pending-${Date.now()}`,
|
||||
status: 'sending',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
messageCache.addMessage(conversationUUID, pendingMessage)
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
|
||||
// If messages are already loaded, do nothing.
|
||||
if (messageCache.hasConversation(conversationUUID)) {
|
||||
return
|
||||
}
|
||||
// Update conversations list with pending message
|
||||
updateConversationListLastMessage(conversationUUID, pendingMessage)
|
||||
|
||||
// Fetch entire conversation and replace messages and conversation data
|
||||
try {
|
||||
const resp = await api.getChatConversation(conversationUUID)
|
||||
replaceMessages(resp.data.data.messages)
|
||||
currentConversation.value = resp.data.data.conversation
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversation:', error)
|
||||
}
|
||||
return pendingMessage.uuid
|
||||
}
|
||||
|
||||
const fetchAndReplaceConversationAndMessages = async () => {
|
||||
const conversationUUID = currentConversation.value?.uuid
|
||||
if (!conversationUUID) return
|
||||
// Fetch entire conversation and replace messages
|
||||
const replaceMessage = (conversationUUID, msgID, actualMessage) => {
|
||||
messageCache.updateMessage(conversationUUID, msgID, actualMessage)
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
updateConversationListLastMessage(conversationUUID, actualMessage)
|
||||
}
|
||||
|
||||
const removeMessage = (conversationUUID, msgID) => {
|
||||
messageCache.removeMessage(conversationUUID, msgID)
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
}
|
||||
|
||||
const loadConversation = async (conversationUUID, force = false) => {
|
||||
if (!conversationUUID) return false
|
||||
|
||||
// If the conversation is already loaded, do not fetch again unless forced.
|
||||
if (currentConversation.value?.uuid === conversationUUID && !force) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.getChatConversation(conversationUUID)
|
||||
setCurrentConversation(resp.data.data.conversation)
|
||||
replaceMessages(resp.data.data.messages)
|
||||
currentConversation.value = resp.data.data.conversation
|
||||
// Set last message for the conversation
|
||||
const conv = conversations.value.find(c => c.uuid === conversationUUID)
|
||||
if (conv) {
|
||||
conv.last_message = resp.data.data.messages[0]?.content || ''
|
||||
conv.last_message_at = resp.data.data.messages[0]?.created_at || new Date().toISOString()
|
||||
if (resp.data.data.messages.length > 0) {
|
||||
updateConversationListLastMessage(conversationUUID, resp.data.data.messages[0], false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversation:', error)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const replaceMessages = (newMessages) => {
|
||||
@@ -93,16 +138,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messageCache.purgeConversation(convId)
|
||||
messageCache.addMessages(convId, newMessages, 1, 1)
|
||||
}
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
const convId = currentConversation.value?.uuid
|
||||
if (!convId) return
|
||||
// Clear messages for current conversation by setting empty array.
|
||||
// Clear messages for current conversation by setting empty values.
|
||||
messageCache.addMessages(convId, [], 1, 1)
|
||||
messageCacheVersion.value++ // Trigger reactivity
|
||||
}
|
||||
|
||||
const setTypingStatus = (status) => {
|
||||
const setTypingStatus = (conversationUUID, status) => {
|
||||
if (!conversationUUID) return
|
||||
if (currentConversation.value?.uuid !== conversationUUID) {
|
||||
return
|
||||
}
|
||||
isTyping.value = status
|
||||
}
|
||||
|
||||
@@ -110,27 +161,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (conversation === null) {
|
||||
conversation = {}
|
||||
}
|
||||
// Clear messages if conversation is null or empty.
|
||||
if (!conversation) {
|
||||
clearMessages()
|
||||
}
|
||||
currentConversation.value = conversation
|
||||
}
|
||||
|
||||
const openConversation = (conversation) => {
|
||||
// Set the current conversation
|
||||
setCurrentConversation(conversation)
|
||||
|
||||
// Init WebSocket connection if not already initialized.
|
||||
const jwt = userStore.userSessionToken
|
||||
if (jwt) {
|
||||
initWidgetWS(jwt)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConversations = async () => {
|
||||
// No session token means no conversations can be fetched simply return empty.
|
||||
if (!userStore.userSessionToken) {
|
||||
conversations.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (conversations.value !== null) {
|
||||
// If conversations are already loaded and is an array, do not fetch again.
|
||||
if (Array.isArray(conversations.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +200,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const updateCurrentConversationLastSeen = async () => {
|
||||
const conversationUUID = currentConversation.value?.uuid
|
||||
if (!conversationUUID) return
|
||||
api.updateConversationLastSeen(conversationUUID)
|
||||
|
||||
// Reset unread count for current conversation
|
||||
if (conversations.value && Array.isArray(conversations.value)) {
|
||||
const conv = conversations.value.find(c => c.uuid === conversationUUID)
|
||||
if (conv) {
|
||||
conv.unread_message_count = 0
|
||||
}
|
||||
}
|
||||
await api.updateConversationLastSeen(conversationUUID)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -172,14 +226,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
// Actions
|
||||
addMessageToConversation,
|
||||
fetchCurrentConversation,
|
||||
addPendingMessage,
|
||||
replaceMessage,
|
||||
removeMessage,
|
||||
replaceMessages,
|
||||
clearMessages,
|
||||
setTypingStatus,
|
||||
setCurrentConversation,
|
||||
openConversation,
|
||||
fetchConversations,
|
||||
fetchAndReplaceConversationAndMessages,
|
||||
updateCurrentConversationLastSeen
|
||||
loadConversation,
|
||||
updateCurrentConversationLastSeen,
|
||||
updateConversationListLastMessage
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,13 +14,29 @@ export const useUserStore = defineStore('user', () => {
|
||||
return jwt.is_visitor
|
||||
})
|
||||
|
||||
const userID = computed(() => {
|
||||
const token = userSessionToken.value
|
||||
if (!token) return null
|
||||
const jwt = parseJWT(token)
|
||||
return jwt.user_id || null
|
||||
})
|
||||
|
||||
const clearSessionToken = () => {
|
||||
userSessionToken.value = ""
|
||||
}
|
||||
|
||||
const setSessionToken = (token) => {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('Session token must be a string')
|
||||
}
|
||||
userSessionToken.value = token
|
||||
}
|
||||
|
||||
return {
|
||||
userSessionToken,
|
||||
isVisitor,
|
||||
clearSessionToken
|
||||
userID,
|
||||
clearSessionToken,
|
||||
setSessionToken
|
||||
}
|
||||
})
|
||||
@@ -7,6 +7,7 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
const currentView = ref('home')
|
||||
const config = ref({})
|
||||
const isInChatView = ref(false)
|
||||
const isMobileFullScreen = ref(false)
|
||||
|
||||
|
||||
// Getters
|
||||
@@ -49,12 +50,17 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
config.value = { ...newConfig }
|
||||
}
|
||||
|
||||
const setMobileFullScreen = (isMobile) => {
|
||||
isMobileFullScreen.value = isMobile
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isOpen,
|
||||
currentView,
|
||||
config,
|
||||
isInChatView,
|
||||
isMobileFullScreen,
|
||||
|
||||
// Getters
|
||||
isHomeView,
|
||||
@@ -69,5 +75,6 @@ export const useWidgetStore = defineStore('widget', () => {
|
||||
navigateToMessages,
|
||||
navigateToHome,
|
||||
updateConfig,
|
||||
setMobileFullScreen,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,402 +1,35 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Chat Header -->
|
||||
<div class="flex items-center p-4 border-b border-border bg-background gap-3 relative">
|
||||
<Button @click="goBack" variant="ghost" size="sm" class="absolute left-2">
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
<!-- Chat header -->
|
||||
<ChatHeader @goBack="goBack" />
|
||||
|
||||
<!-- Title -->
|
||||
<div class="flex items-center justify-center gap-3 ml-10">
|
||||
<Avatar class="size-10">
|
||||
<AvatarImage :src="chatTitle.avatarUrl" />
|
||||
<AvatarFallback>
|
||||
{{ chatTitle.avatarFallback }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-base font-bold text-foreground">
|
||||
{{ chatTitle.name }}
|
||||
</h3>
|
||||
<p v-if="chatTitle.showStatus" class="text-xs text-muted-foreground">
|
||||
<span v-if="chatTitle.isOnline">
|
||||
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>
|
||||
{{ chatTitle.statusText }}
|
||||
</span>
|
||||
<span v-else> Active {{ chatTitle.lastActiveText }} </span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Messages container -->
|
||||
<ChatMessages ref="chatMessages" />
|
||||
|
||||
<!-- Messages Container -->
|
||||
<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"
|
||||
ref="messagesContainer"
|
||||
>
|
||||
<!-- Chat Intro -->
|
||||
<ChatIntro :introText="config.chat_introduction" />
|
||||
|
||||
<!-- Notice -->
|
||||
<NoticeBanner
|
||||
v-if="config.notice_banner.enabled === true"
|
||||
:noticeText="config.notice_banner.text"
|
||||
/>
|
||||
|
||||
<!-- Messages -->
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.uuid"
|
||||
:class="[
|
||||
'flex flex-col animate-slide-in',
|
||||
message.sender_type === 'contact' ? 'items-end' : 'items-start'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-[85%] px-4 py-3 rounded-2xl text-sm leading-5 break-words',
|
||||
message.sender_type === 'contact'
|
||||
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||||
: 'bg-background text-foreground rounded-bl-sm border border-border'
|
||||
]"
|
||||
>
|
||||
<Letter
|
||||
:html="message.content"
|
||||
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
|
||||
class="mb-1 native-html"
|
||||
/>
|
||||
<!-- Show attachments if available -->
|
||||
<MessageAttachment
|
||||
v-if="message.attachments && message.attachments.length > 0"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ getMessageTime(message.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
<div v-if="isTyping" class="flex flex-col items-start">
|
||||
<div
|
||||
class="flex items-center px-4 py-3 bg-background text-foreground rounded-2xl rounded-bl-sm border border-border"
|
||||
>
|
||||
<div class="flex gap-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-dot-flashing"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-dot-flashing"
|
||||
style="animation-delay: 0.2s"
|
||||
></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-dot-flashing"
|
||||
style="animation-delay: 0.4s"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<!-- Error display -->
|
||||
<WidgetError :errorMessage="errorMessage" />
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="border-t flex-shrink-0 focus:ring-0 focus:outline-none">
|
||||
<div class="p-3">
|
||||
<!-- Unified Input Container -->
|
||||
<div class="border border-input rounded-lg bg-background focus-within:border-primary">
|
||||
<!-- Textarea Container -->
|
||||
<div class="p-3 pb-2">
|
||||
<Textarea
|
||||
v-model="newMessage"
|
||||
@keydown="handleKeydown"
|
||||
@input="handleTyping"
|
||||
placeholder="Type your message..."
|
||||
class="w-full min-h-6 max-h-32 resize-none border-0 bg-transparent focus:ring-0 focus:outline-none focus-visible:ring-0 p-0 shadow-none"
|
||||
ref="messageInput"
|
||||
></Textarea>
|
||||
</div>
|
||||
|
||||
<!-- Actions and Send Button -->
|
||||
<div class="flex justify-between items-center px-3 py-2">
|
||||
<!-- Message Input Actions (file upload + emoji) -->
|
||||
<MessageInputActions
|
||||
:fileUploadEnabled="config.features?.file_upload || false"
|
||||
:emojiEnabled="config.features?.emoji || false"
|
||||
:uploading="isUploading"
|
||||
@fileUpload="handleFileUpload"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
|
||||
<!-- Send Button -->
|
||||
<Button
|
||||
@click="sendMessage"
|
||||
size="sm"
|
||||
class="h-8 px-2 rounded-full disabled:opacity-50 disabled:cursor-not-allowed border-0"
|
||||
:disabled="!newMessage.trim() || isUploading"
|
||||
>
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Message input -->
|
||||
<MessageInput @error="handleError" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { ArrowLeft, ArrowUp } from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Textarea } from '@shared-ui/components/ui/textarea'
|
||||
import { ref } from 'vue'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { sendWidgetTyping } from '../websocket.js'
|
||||
import { debounce } from '@shared-ui/utils/debounce.js'
|
||||
import { Letter } from 'vue-letter'
|
||||
import { convertTextToHtml } from '@shared-ui/utils/string.js'
|
||||
import ChatIntro from '@widget/components/ChatIntro.vue'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import NoticeBanner from '@widget/components/NoticeBanner.vue'
|
||||
import WidgetError from '@widget/components/WidgetError.vue'
|
||||
import MessageAttachment from '@widget/components/MessageAttachment.vue'
|
||||
import MessageInputActions from '@widget/components/MessageInputActions.vue'
|
||||
import api from '@widget/api/index.js'
|
||||
import ChatHeader from '@widget/components/ChatHeader.vue'
|
||||
import ChatMessages from '@widget/components/ChatMessages.vue'
|
||||
import MessageInput from '@widget/components/MessageInput.vue'
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const messagesContainer = ref(null)
|
||||
const messageInput = ref(null)
|
||||
const newMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
const timeUpdateTrigger = ref(0) // Force reactivity for time updates
|
||||
const isUploading = ref(false)
|
||||
|
||||
|
||||
const config = computed(() => widgetStore.config)
|
||||
const isTyping = computed(() => chatStore.isTyping)
|
||||
const messages = computed(() => chatStore.getCurrentConversationMessages())
|
||||
const chatTitle = computed(() => {
|
||||
const assignee = chatStore.currentConversation?.assignee
|
||||
const hasAssignee = assignee?.id > 0
|
||||
|
||||
if (hasAssignee) {
|
||||
return {
|
||||
name: assignee.first_name,
|
||||
avatarUrl: assignee.avatar_url || '',
|
||||
avatarFallback: assignee.first_name?.charAt(0).toUpperCase() || 'A',
|
||||
showStatus: !!assignee.active_at,
|
||||
isOnline: assignee.availability_status === 'online',
|
||||
statusText:
|
||||
assignee.availability_status?.charAt(0).toUpperCase() +
|
||||
assignee.availability_status?.slice(1),
|
||||
lastActiveText: assignee.active_at ? getRelativeTime(assignee.active_at).toLowerCase() : ''
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: config.value.brand_name,
|
||||
avatarUrl: '',
|
||||
avatarFallback: config.value.brand_name?.charAt(0).toUpperCase() || 'B',
|
||||
showStatus: false,
|
||||
isOnline: false,
|
||||
statusText: '',
|
||||
lastActiveText: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getMessageTime = computed(() => {
|
||||
timeUpdateTrigger.value
|
||||
return (timestamp) => getRelativeTime(timestamp)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const goBack = () => {
|
||||
widgetStore.navigateToMessages()
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim()) return
|
||||
|
||||
// Convert text to HTML.
|
||||
const messageText = convertTextToHtml(newMessage.value.trim())
|
||||
|
||||
newMessage.value = ''
|
||||
|
||||
try {
|
||||
let resp = {}
|
||||
// No current conversation ID? Start a new conversation.
|
||||
if (!chatStore.currentConversation.uuid) {
|
||||
resp = await api.initChatConversation({
|
||||
message: messageText
|
||||
})
|
||||
let data = resp.data.data
|
||||
|
||||
const conversation = data.conversation
|
||||
const conversationUUID = data.conversation.uuid
|
||||
|
||||
if (!localStorage.getItem('libredesk_session')) {
|
||||
localStorage.setItem('libredesk_session', data.jwt)
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
|
||||
// Fetch entire conversation and replace messages
|
||||
if (conversationUUID) {
|
||||
// Use the openConversation method which handles both setting ID and WebSocket joining
|
||||
chatStore.openConversation(conversation)
|
||||
|
||||
let resp = await api.getChatConversation(conversationUUID)
|
||||
let msgs = resp.data.data.messages
|
||||
|
||||
// Replace all messages with the fetched conversation
|
||||
chatStore.replaceMessages(msgs)
|
||||
}
|
||||
} else {
|
||||
// Send message in existing conversation
|
||||
await api.sendChatMessage(chatStore.currentConversation.uuid, {
|
||||
message: messageText
|
||||
})
|
||||
await chatStore.fetchAndReplaceConversationAndMessages()
|
||||
}
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('libredesk_session')
|
||||
chatStore.setCurrentConversation(null)
|
||||
}
|
||||
errorMessage.value = handleHTTPError(error).message
|
||||
} finally {
|
||||
await chatStore.updateCurrentConversationLastSeen()
|
||||
}
|
||||
const handleError = (message) => {
|
||||
errorMessage.value = message
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let timeUpdateTimer = null
|
||||
|
||||
// Debounced typing functions
|
||||
const sendTypingStart = debounce(() => {
|
||||
if (chatStore.currentConversation.uuid) {
|
||||
sendWidgetTyping(true)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
const sendTypingStop = debounce(() => {
|
||||
if (chatStore.currentConversation.uuid) {
|
||||
sendWidgetTyping(false)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
const handleTyping = () => {
|
||||
sendTypingStart()
|
||||
sendTypingStop()
|
||||
}
|
||||
|
||||
// Handle Enter vs Shift+Enter
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
// Allow Shift+Enter for new lines (default behavior)
|
||||
}
|
||||
|
||||
// File upload handler
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!chatStore.currentConversation.uuid || files.length === 0) return
|
||||
|
||||
isUploading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// Upload files using the widget API
|
||||
await api.uploadMedia(chatStore.currentConversation.uuid, files)
|
||||
|
||||
// Refresh conversation to get updated messages with attachments
|
||||
const resp = await api.getChatConversation(chatStore.currentConversation.uuid)
|
||||
const msgs = resp.data.data.messages
|
||||
chatStore.replaceMessages(msgs)
|
||||
|
||||
// Scroll to bottom to show new messages
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error)
|
||||
errorMessage.value = handleHTTPError(error).message
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji selection handler
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
if (messageInput.value) {
|
||||
const textarea = messageInput.value.$el.querySelector('textarea') || messageInput.value.$el
|
||||
if (textarea) {
|
||||
const cursorPos = textarea.selectionStart || 0
|
||||
const textBefore = newMessage.value.substring(0, cursorPos)
|
||||
const textAfter = newMessage.value.substring(cursorPos)
|
||||
newMessage.value = textBefore + emoji + textAfter
|
||||
|
||||
// Set cursor position after the emoji
|
||||
nextTick(() => {
|
||||
const newPos = cursorPos + emoji.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
})
|
||||
} else {
|
||||
// Fallback: just append emoji
|
||||
newMessage.value += emoji
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await chatStore.fetchCurrentConversation()
|
||||
|
||||
// Start timer to update relative times every 1 minute
|
||||
timeUpdateTimer = setInterval(() => {
|
||||
timeUpdateTrigger.value++
|
||||
}, 60000)
|
||||
|
||||
// Scroll to bottom on mount
|
||||
scrollToBottom()
|
||||
|
||||
// Update last seen timestamp for the contact
|
||||
await chatStore.updateCurrentConversationLastSeen()
|
||||
})
|
||||
|
||||
// Watch for new messages and scroll to bottom
|
||||
watch(
|
||||
messages,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Auto-resize textarea
|
||||
watch(newMessage, () => {
|
||||
nextTick(() => {
|
||||
if (messageInput.value?.$el) {
|
||||
const textarea = messageInput.value.$el
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 128) + 'px'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (timeUpdateTimer) {
|
||||
clearInterval(timeUpdateTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,97 +1,74 @@
|
||||
<template>
|
||||
<div class="flex flex-col p-4 h-full gap-5">
|
||||
<div class="p-2">
|
||||
<!-- Logo Section -->
|
||||
<img v-if="config.logo_url" :src="config.logo_url" alt="Logo" class="max-h-8 max-w-full" />
|
||||
|
||||
<!-- Welcome Message -->
|
||||
<div class="mt-24 font-medium text-4xl">
|
||||
<h2>{{ config.greeting_message || 'Hi there' }}</h2>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
{{ config.introduction_message || 'How can we help?' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col h-full relative">
|
||||
<div class="absolute top-2 right-4 z-10">
|
||||
<CloseWidgetButton />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50">
|
||||
<div class="flex flex-col gap-6">
|
||||
<HomeHeader :config="config" />
|
||||
|
||||
<!-- Start Conversation Button -->
|
||||
<div class="mt-3">
|
||||
<div>
|
||||
<Button
|
||||
v-if="canStartConversation"
|
||||
@click="startConversation"
|
||||
class="w-full h-12"
|
||||
size="lg"
|
||||
>
|
||||
{{ getStartButtonText() }}
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Show recent conversation if exists -->
|
||||
<RecentConversationCard
|
||||
v-if="mostRecentConversation"
|
||||
:conversation="mostRecentConversation"
|
||||
/>
|
||||
|
||||
<!-- Start new conversation button if no recent conversation -->
|
||||
<div v-if="canStartConversation && !mostRecentConversation">
|
||||
<Button @click="startConversation" class="w-full flex items-center justify-center">
|
||||
{{ startButtonText }}
|
||||
<ArrowRight class="w-4 h-4 ml-2 inline-block" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- External Links -->
|
||||
<HomeExternalLinks :config="config" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External Links -->
|
||||
<div v-if="config.external_links?.length" class="mt-2 space-y-2">
|
||||
<a
|
||||
v-for="link in config.external_links"
|
||||
:key="link.url"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block no-underline"
|
||||
>
|
||||
<Card class="hover:bg-accent transition-colors cursor-pointer rounded-md">
|
||||
<CardContent class="p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-primary font-medium">
|
||||
{{ link.text }}
|
||||
</span>
|
||||
<ExternalLink size="18" class="text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { ArrowRight, ExternalLink } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Card, CardContent } from '@shared-ui/components/ui/card'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { useWidgetStore } from '@widget/store/widget.js'
|
||||
import { useChatStore } from '@widget/store/chat.js'
|
||||
import { useUserStore } from '@widget/store/user.js'
|
||||
import HomeHeader from '@widget/components/HomeHeader.vue'
|
||||
import HomeExternalLinks from '@widget/components/HomeExternalLinks.vue'
|
||||
import RecentConversationCard from '@widget/components/RecentConversationCard.vue'
|
||||
import CloseWidgetButton from '@widget/components/CloseWidgetButton.vue'
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
const config = computed(() => widgetStore.config)
|
||||
|
||||
const mostRecentConversation = computed(() => {
|
||||
const conversations = chatStore.getConversations
|
||||
if (!conversations || conversations.length === 0) return null
|
||||
// Get the most recent conversation (already sorted by last_message.created_at in the store)
|
||||
return conversations[0]
|
||||
})
|
||||
|
||||
const canStartConversation = computed(() => {
|
||||
const userConfig = userStore.isVisitor ? config.value.visitors : config.value.users
|
||||
return userConfig?.prevent_multiple_conversations !== true || !chatStore.hasConversations
|
||||
})
|
||||
|
||||
const getStartButtonText = () => {
|
||||
const startButtonText = computed(() => {
|
||||
const isVisitor = userStore.isVisitor
|
||||
return isVisitor
|
||||
? config.value.visitors?.start_conversation_button_text || 'Send us a message'
|
||||
: config.value.users?.start_conversation_button_text || 'Send us a message'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.fetchConversations()
|
||||
})
|
||||
|
||||
const startConversation = () => {
|
||||
// Clear current conversation for new one
|
||||
chatStore.setCurrentConversation(null)
|
||||
chatStore.clearMessages()
|
||||
// Navigate to messages tab and then to chat view
|
||||
widgetStore.navigateToMessages()
|
||||
// Use nextTick to ensure we're on messages tab first, then navigate to chat
|
||||
setTimeout(() => {
|
||||
widgetStore.navigateToChat()
|
||||
}, 0)
|
||||
widgetStore.navigateToChat()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,63 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-center p-4 border-b">
|
||||
<h3 class="text-base font-semibold text-foreground">Messages</h3>
|
||||
</div>
|
||||
<WidgetHeader title="Messages" />
|
||||
|
||||
<!-- Conversations List -->
|
||||
<!-- Messages List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingConversations" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="!hasConversations"
|
||||
class="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
<MessageCircleDashed class="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<h4 class="text-sm text-muted-foreground mb-2">No messages yet</h4>
|
||||
</div>
|
||||
|
||||
<!-- Conversations -->
|
||||
<div v-else class="divide-y divide-border">
|
||||
<div
|
||||
v-for="conversation in getConversations"
|
||||
:key="conversation.uuid"
|
||||
@click="openConversation(conversation)"
|
||||
class="p-4 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-foreground text-sm truncate mb-1">
|
||||
{{ conversation.last_message }}
|
||||
</h4>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ widgetStore.config?.brand_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span
|
||||
v-if="conversation.unread_message_count > 0"
|
||||
class="bg-primary text-primary-foreground text-xs font-medium rounded-full px-2 py-1"
|
||||
>
|
||||
{{
|
||||
conversation.unread_message_count > 10 ? '9+' : conversation.unread_message_count
|
||||
}}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ getConversationTime(conversation.last_message_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MessagesList />
|
||||
</div>
|
||||
|
||||
<!-- New Conversation Button -->
|
||||
<!-- New conversation button -->
|
||||
<div class="p-4 border-border mx-auto" v-if="canStartNewConversation">
|
||||
<Button @click="startNewConversation">
|
||||
{{ widgetStore.config?.users?.start_conversation_button_text || 'Start new conversation' }}
|
||||
@@ -67,27 +18,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { MessageCircleDashed } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { useChatStore } from '../store/chat.js'
|
||||
import { useWidgetStore } from '../store/widget.js'
|
||||
import { useUserStore } from '@widget/store/user.js'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
import MessagesList from '../components/MessagesList.vue'
|
||||
import WidgetHeader from '../layouts/WidgetHeader.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const widgetStore = useWidgetStore()
|
||||
const userStore = useUserStore()
|
||||
const timeUpdateTrigger = ref(0)
|
||||
|
||||
const hasConversations = computed(() => chatStore.hasConversations)
|
||||
const getConversations = computed(() => chatStore.getConversations)
|
||||
const isLoadingConversations = computed(() => chatStore.isLoadingConversations)
|
||||
|
||||
const getConversationTime = computed(() => {
|
||||
timeUpdateTrigger.value // Force reactivity
|
||||
return (timestamp) => getRelativeTime(timestamp)
|
||||
})
|
||||
|
||||
const canStartNewConversation = computed(() => {
|
||||
const isVisitor = userStore.isVisitor
|
||||
@@ -104,14 +45,6 @@ const canStartNewConversation = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const openConversation = (conversation) => {
|
||||
// Set the current conversation in chat store.
|
||||
chatStore.openConversation(conversation)
|
||||
|
||||
// Navigate to chat view
|
||||
widgetStore.navigateToChat()
|
||||
}
|
||||
|
||||
const startNewConversation = () => {
|
||||
// Clear current conversation
|
||||
chatStore.setCurrentConversation(null)
|
||||
@@ -120,20 +53,4 @@ const startNewConversation = () => {
|
||||
// Navigate directly to chat view
|
||||
widgetStore.navigateToChat()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chatStore.fetchConversations()
|
||||
|
||||
// Update relative times every minute
|
||||
const timeUpdateTimer = setInterval(() => {
|
||||
timeUpdateTrigger.value++
|
||||
}, 60000)
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (timeUpdateTimer) {
|
||||
clearInterval(timeUpdateTimer)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useChatStore } from './store/chat'
|
||||
|
||||
// Widget WebSocket message types (matching backend constants)
|
||||
import { useChatStore } from './store/chat.js'
|
||||
|
||||
export const WS_EVENT = {
|
||||
JOIN: 'join',
|
||||
MESSAGE: 'message',
|
||||
@@ -23,13 +23,14 @@ export class WidgetWebSocketClient {
|
||||
this.manualClose = false
|
||||
this.pingInterval = null
|
||||
this.lastPong = Date.now()
|
||||
this.chatStore = useChatStore()
|
||||
this.jwt = null
|
||||
this.isJoined = false
|
||||
this.inboxId = null
|
||||
}
|
||||
|
||||
init (jwt) {
|
||||
init (jwt, inboxId) {
|
||||
this.manualClose = false
|
||||
this.jwt = jwt
|
||||
this.inboxId = inboxId
|
||||
this.connect()
|
||||
this.setupNetworkListeners()
|
||||
}
|
||||
@@ -50,44 +51,48 @@ export class WidgetWebSocketClient {
|
||||
}
|
||||
|
||||
handleOpen () {
|
||||
console.log('Widget WebSocket connected')
|
||||
this.reconnectInterval = 1000
|
||||
const wasReconnecting = this.reconnectAttempts > 0
|
||||
this.reconnectAttempts = 0
|
||||
this.isReconnecting = false
|
||||
this.lastPong = Date.now()
|
||||
this.setupPing()
|
||||
|
||||
// Auto-join conversation after connection if a conversation uuid is set.
|
||||
if (this.chatStore.currentConversation.uuid && this.jwt && !this.isJoined) {
|
||||
this.joinConversation()
|
||||
// Auto-join inbox after connection if inbox_id is set.
|
||||
if (this.inboxId && this.jwt) {
|
||||
this.joinInbox()
|
||||
}
|
||||
|
||||
// If this was a reconnection, sync current conversation messages
|
||||
if (wasReconnecting) {
|
||||
this.resyncCurrentConversation()
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage (event) {
|
||||
const chatStore = useChatStore()
|
||||
try {
|
||||
if (!event.data) return
|
||||
const data = JSON.parse(event.data)
|
||||
const handlers = {
|
||||
[WS_EVENT.JOINED]: () => {
|
||||
this.isJoined = true
|
||||
// Joined inbox.
|
||||
},
|
||||
[WS_EVENT.PONG]: () => {
|
||||
this.lastPong = Date.now()
|
||||
},
|
||||
[WS_EVENT.NEW_MESSAGE]: () => {
|
||||
// Add new message to chat store
|
||||
if (data.data) {
|
||||
this.chatStore.addMessageToConversation(data.data.conversation_uuid, data.data)
|
||||
chatStore.addMessageToConversation(data.data.conversation_uuid, data.data)
|
||||
}
|
||||
},
|
||||
[WS_EVENT.ERROR]: () => {
|
||||
console.error('Widget WebSocket error:', data.data)
|
||||
},
|
||||
[WS_EVENT.TYPING]: () => {
|
||||
// TODO: check conversation uuid and then set typing as true.
|
||||
// if (data.data && data.data.is_typing !== undefined) {
|
||||
// this.chatStore.setTypingStatus(data.data.is_typing)
|
||||
// }
|
||||
if (data.data && data.data.is_typing !== undefined) {
|
||||
chatStore.setTypingStatus(data.data.conversation_uuid, data.data.is_typing)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handler = handlers[data.type]
|
||||
@@ -108,7 +113,6 @@ export class WidgetWebSocketClient {
|
||||
|
||||
handleClose () {
|
||||
this.clearPing()
|
||||
this.isJoined = false
|
||||
if (!this.manualClose) {
|
||||
this.reconnect()
|
||||
}
|
||||
@@ -169,10 +173,9 @@ export class WidgetWebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
joinConversation () {
|
||||
const currentConversationUuid = this.chatStore.currentConversation.uuid
|
||||
if (!currentConversationUuid || !this.jwt) {
|
||||
console.error('Cannot join conversation: missing conversationUuid or JWT')
|
||||
joinInbox () {
|
||||
if (!this.inboxId || !this.jwt) {
|
||||
console.error('Cannot join inbox: missing inbox_id or JWT')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,29 +183,31 @@ export class WidgetWebSocketClient {
|
||||
type: WS_EVENT.JOIN,
|
||||
jwt: this.jwt,
|
||||
data: {
|
||||
conversation_uuid: currentConversationUuid
|
||||
inbox_id: parseInt(this.inboxId, 10)
|
||||
}
|
||||
}
|
||||
|
||||
this.send(joinMessage)
|
||||
}
|
||||
|
||||
sendTyping (isTyping = true) {
|
||||
if (!this.isJoined) {
|
||||
console.warn('Cannot send typing indicator: not joined to conversation')
|
||||
return
|
||||
// Resync current conversation after reconnection to catch any missed messages.
|
||||
resyncCurrentConversation () {
|
||||
const chatStore = useChatStore()
|
||||
const currentConversationUUID = chatStore.currentConversation?.uuid
|
||||
if (currentConversationUUID) {
|
||||
chatStore.loadConversation(currentConversationUUID)
|
||||
}
|
||||
}
|
||||
|
||||
const currentConversationUUID = this.chatStore.currentConversation.uuid
|
||||
sendTyping (isTyping = true, conversationUUID = null) {
|
||||
const typingMessage = {
|
||||
type: WS_EVENT.TYPING,
|
||||
jwt: this.jwt,
|
||||
data: {
|
||||
conversation_uuid: currentConversationUUID,
|
||||
conversation_uuid: conversationUUID,
|
||||
is_typing: isTyping
|
||||
}
|
||||
}
|
||||
|
||||
this.send(typingMessage)
|
||||
}
|
||||
|
||||
@@ -214,19 +219,8 @@ export class WidgetWebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Method to join a new conversation without reinitializing the connection
|
||||
joinNewConversation () {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.isJoined = false
|
||||
this.joinConversation()
|
||||
} else {
|
||||
console.warn('WebSocket not connected, cannot join new conversation')
|
||||
}
|
||||
}
|
||||
|
||||
close () {
|
||||
this.manualClose = true
|
||||
this.isJoined = false
|
||||
this.clearPing()
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
@@ -236,26 +230,25 @@ export class WidgetWebSocketClient {
|
||||
|
||||
let widgetWSClient
|
||||
|
||||
export function initWidgetWS (jwt) {
|
||||
export function initWidgetWS (jwt, inboxId) {
|
||||
if (!widgetWSClient) {
|
||||
widgetWSClient = new WidgetWebSocketClient()
|
||||
widgetWSClient.init(jwt)
|
||||
widgetWSClient.init(jwt, inboxId)
|
||||
} else {
|
||||
// Update JWT and rejoin if connection exists
|
||||
// Update JWT and inbox_id and rejoin if connection exists
|
||||
widgetWSClient.jwt = jwt
|
||||
widgetWSClient.inboxId = inboxId
|
||||
if (widgetWSClient.socket?.readyState === WebSocket.OPEN) {
|
||||
// Reset joined status and join the new conversation
|
||||
widgetWSClient.isJoined = false
|
||||
widgetWSClient.joinConversation()
|
||||
widgetWSClient.joinInbox()
|
||||
} else {
|
||||
// If connection is not open, reconnect
|
||||
widgetWSClient.init(jwt)
|
||||
widgetWSClient.init(jwt, inboxId)
|
||||
}
|
||||
}
|
||||
return widgetWSClient
|
||||
}
|
||||
|
||||
export const sendWidgetMessage = message => widgetWSClient?.send(message)
|
||||
export const sendWidgetTyping = (isTyping = true) => widgetWSClient?.sendTyping(isTyping)
|
||||
export const sendWidgetTyping = (isTyping = true, conversationUUID = null) => widgetWSClient?.sendTyping(isTyping, conversationUUID)
|
||||
export const closeWidgetWebSocket = () => widgetWSClient?.close()
|
||||
export const joinNewConversation = () => widgetWSClient?.joinNewConversation()
|
||||
export const reOpenWidgetWebSocket = () => widgetWSClient?.reOpen()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
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-1"
|
||||
>
|
||||
<div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
|
||||
<button
|
||||
@click="$emit('scroll-to-bottom')"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-background text-primary transition-colors duration-200 hover:bg-accent dark:hover:bg-gray-700"
|
||||
>
|
||||
<ChevronDown class="w-4 h-4" />
|
||||
</button>
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[20px] h-5 px-1.5 rounded-full bg-green-500 text-primary-foreground text-xs font-medium flex items-center justify-center"
|
||||
>
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
isAtBottom: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
unreadCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['scroll-to-bottom'])
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ScrollToBottomButton.vue'
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2 px-3 py-2 text-sm text-muted-foreground">
|
||||
<div class="flex space-x-1">
|
||||
<div class="w-2 h-2 bg-current rounded-full animate-bounce" style="animation-delay: 0ms"></div>
|
||||
<div class="w-2 h-2 bg-current rounded-full animate-bounce" style="animation-delay: 150ms"></div>
|
||||
<div class="w-2 h-2 bg-current rounded-full animate-bounce" style="animation-delay: 300ms"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TypingIndicator } from './TypingIndicator.vue'
|
||||
@@ -1,14 +0,0 @@
|
||||
// Button component exports
|
||||
export { Button, buttonVariants } from './ui/button'
|
||||
|
||||
// Card component exports
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './ui/card'
|
||||
|
||||
// Input component exports
|
||||
export { Input } from './ui/input'
|
||||
|
||||
// Add other component exports as needed
|
||||
// Example:
|
||||
// export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog'
|
||||
// export { Badge, badgeVariants } from './ui/badge'
|
||||
// export { Avatar, AvatarImage, AvatarFallback } from './ui/avatar'
|
||||
@@ -0,0 +1 @@
|
||||
export { useTypingIndicator } from './useTypingIndicator.js'
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useTypingIndicator(sendTypingCallback) {
|
||||
const typingTimer = ref(null)
|
||||
const isCurrentlyTyping = ref(false)
|
||||
|
||||
const startTyping = () => {
|
||||
if (!isCurrentlyTyping.value) {
|
||||
isCurrentlyTyping.value = true
|
||||
sendTypingCallback?.(true)
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (typingTimer.value) {
|
||||
clearTimeout(typingTimer.value)
|
||||
}
|
||||
|
||||
// Set timer to stop typing after 2 seconds of inactivity
|
||||
typingTimer.value = setTimeout(() => {
|
||||
stopTyping()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const stopTyping = () => {
|
||||
if (isCurrentlyTyping.value) {
|
||||
isCurrentlyTyping.value = false
|
||||
sendTypingCallback?.(false)
|
||||
}
|
||||
|
||||
if (typingTimer.value) {
|
||||
clearTimeout(typingTimer.value)
|
||||
typingTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
onUnmounted(() => {
|
||||
stopTyping()
|
||||
})
|
||||
|
||||
return {
|
||||
startTyping,
|
||||
stopTyping,
|
||||
isCurrentlyTyping
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ export function getRelativeTime (timestamp, now = new Date()) {
|
||||
const days = differenceInDays(now, timestamp)
|
||||
|
||||
if (mins === 0) return 'Just now'
|
||||
if (mins < 60) return `${mins} mins ago`
|
||||
if (hours < 24) return `${hours} hrs ago`
|
||||
if (days < 7) return `${days} days ago`
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
return format(timestamp, 'MMMM d, yyyy h:mm a')
|
||||
} catch (error) {
|
||||
console.error('Error parsing time', error, 'timestamp', timestamp)
|
||||
|
||||
+10
-3
@@ -38,6 +38,7 @@
|
||||
"globals.terms.dashboard": "Dashboard | Dashboards",
|
||||
"globals.terms.tag": "Tag | Tags",
|
||||
"globals.terms.sla": "SLA | SLAs",
|
||||
"globals.terms.feedback": "Feedback | Feedbacks",
|
||||
"globals.terms.slaPolicy": "SLA Policy | SLA Policies",
|
||||
"globals.terms.csatSurvey": "CSAT Survey | CSAT Surveys",
|
||||
"globals.terms.csatResponse": "CSAT Response | CSAT Responses",
|
||||
@@ -264,8 +265,6 @@
|
||||
"globals.messages.type": "{name} type",
|
||||
"globals.messages.typeOf": "Type of {name}",
|
||||
"globals.messages.invalidEmailAddress": "Invalid email address",
|
||||
"globals.messages.invalidColor": "Invalid color format",
|
||||
"globals.messages.invalidUrl": "Invalid URL format",
|
||||
"globals.messages.selectAtLeastOne": "Please select at least one {name}",
|
||||
"globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
|
||||
"globals.messages.couldNotReload": "Could not reload {name}",
|
||||
@@ -276,6 +275,7 @@
|
||||
"globals.messages.notFound": "{name} not found",
|
||||
"globals.messages.empty": "{name} empty",
|
||||
"globals.messages.mismatch": "{name} mismatch",
|
||||
"globals.messages.notAllowed": "{name} not allowed",
|
||||
"globals.messages.errorSendingPasswordResetEmail": "Error sending password reset email",
|
||||
"globals.messages.cannotBeEmpty": "{name} cannot be empty",
|
||||
"globals.messages.pressEnterToSelectAValue": "Press enter to select a value",
|
||||
@@ -470,6 +470,12 @@
|
||||
"admin.inbox.configureChannel": "Configure channel",
|
||||
"admin.inbox.createEmailInbox": "Create Email Inbox",
|
||||
"admin.inbox.livechatConfig": "Live Chat Configuration",
|
||||
"admin.inbox.livechat.tabs.general": "General",
|
||||
"admin.inbox.livechat.tabs.appearance": "Appearance",
|
||||
"admin.inbox.livechat.tabs.messages": "Messages",
|
||||
"admin.inbox.livechat.tabs.features": "Features",
|
||||
"admin.inbox.livechat.tabs.security": "Security",
|
||||
"admin.inbox.livechat.tabs.users": "Users",
|
||||
"admin.inbox.livechat.logoUrl": "Logo URL",
|
||||
"admin.inbox.livechat.logoUrl.description": "URL of the logo to display in the chat widget",
|
||||
"admin.inbox.livechat.secretKey": "Secret Key",
|
||||
@@ -488,6 +494,8 @@
|
||||
"admin.inbox.livechat.introductionMessage": "Introduction Message",
|
||||
"admin.inbox.livechat.chatIntroduction": "Chat Introduction",
|
||||
"admin.inbox.livechat.chatIntroduction.description": "Default: Ask us anything, or share your feedback.",
|
||||
"admin.inbox.livechat.chatReplyExpectationMessage": "Chat reply expectation message",
|
||||
"admin.inbox.livechat.chatReplyExpectationMessage.description": "Message shown to customers during business hours about expected reply times",
|
||||
"admin.inbox.livechat.officeHours": "Office Hours",
|
||||
"admin.inbox.livechat.showOfficeHoursInChat": "Show Office Hours in Chat",
|
||||
"admin.inbox.livechat.showOfficeHoursInChat.description": "Show when the team will be next available",
|
||||
@@ -646,7 +654,6 @@
|
||||
"account.removeAvatar": "Remove avatar",
|
||||
"account.cropAvatar": "Crop avatar",
|
||||
"account.avatarRemoved": "Avatar removed",
|
||||
"conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve",
|
||||
"conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again",
|
||||
"conversation.viewPermissionDenied": "You do not have access to this view",
|
||||
"conversation.errorGeneratingMessageID": "Error generating message ID",
|
||||
|
||||
@@ -38,6 +38,13 @@ func (a *Attachments) Scan(value interface{}) error {
|
||||
return json.Unmarshal(bytes, a)
|
||||
}
|
||||
|
||||
func (a Attachments) MarshalJSON() ([]byte, error) {
|
||||
if a == nil {
|
||||
a = make(Attachments, 0)
|
||||
}
|
||||
return json.Marshal([]Attachment(a))
|
||||
}
|
||||
|
||||
// MakeHeader creates a MIME header for email attachments or inline content.
|
||||
func MakeHeader(contentType, contentID, fileName, encoding, disposition string) textproto.MIMEHeader {
|
||||
if encoding == "" {
|
||||
|
||||
@@ -15,7 +15,10 @@ SELECT id,
|
||||
created_at,
|
||||
updated_at,
|
||||
"name",
|
||||
description
|
||||
description,
|
||||
is_always_open,
|
||||
hours,
|
||||
holidays
|
||||
FROM business_hours
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ type userStore interface {
|
||||
|
||||
type mediaStore interface {
|
||||
GetBlob(name string) ([]byte, error)
|
||||
GetURL(name string) string
|
||||
Attach(id int, model string, modelID int) error
|
||||
GetByModel(id int, model string) ([]mmodels.Media, error)
|
||||
ContentIDExists(contentID string) (bool, string, error)
|
||||
@@ -129,6 +130,7 @@ type settingsStore interface {
|
||||
|
||||
type csatStore interface {
|
||||
Create(conversationID int) (csatModels.CSATResponse, error)
|
||||
Get(uuid string) (csatModels.CSATResponse, error)
|
||||
MakePublicURL(appBaseURL, uuid string) string
|
||||
}
|
||||
|
||||
@@ -1147,3 +1149,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType
|
||||
"conversation_statuses": conversationStatusAllowedFields,
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessCSATStatus processes messages and adds CSAT submission status for CSAT messages.
|
||||
func (m *Manager) ProcessCSATStatus(messages []models.Message) {
|
||||
for i := range messages {
|
||||
msg := &messages[i]
|
||||
if msg.HasCSAT() {
|
||||
// Extract CSAT UUID from message content
|
||||
csatUUID := msg.ExtractCSATUUID()
|
||||
if csatUUID == "" {
|
||||
// Fallback to basic censoring if UUID extraction fails
|
||||
msg.CensorCSATContent()
|
||||
continue
|
||||
}
|
||||
|
||||
// Get CSAT submission status
|
||||
csat, err := m.csatStore.Get(csatUUID)
|
||||
isSubmitted := false
|
||||
rating := 0
|
||||
feedback := ""
|
||||
if err == nil && csat.ResponseTimestamp.Valid {
|
||||
isSubmitted = true
|
||||
rating = csat.Score
|
||||
if csat.Feedback.Valid {
|
||||
feedback = csat.Feedback.String
|
||||
}
|
||||
}
|
||||
|
||||
// Censor content and add submission status
|
||||
msg.CensorCSATContentWithStatus(isSubmitted, csatUUID, rating, feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/abhinavxd/libredesk/internal/sla"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
@@ -102,7 +103,7 @@ func (m *Manager) IncomingMessageWorker(ctx context.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := m.processIncomingMessage(msg); err != nil {
|
||||
if _, err := m.ProcessIncomingMessage(msg); err != nil {
|
||||
m.lo.Error("error processing incoming msg", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -180,11 +181,12 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
|
||||
|
||||
// Send message
|
||||
err = inb.Send(message)
|
||||
if handleError(err, "error sending message") {
|
||||
if err != nil && err != livechat.ErrClientNotConnected {
|
||||
handleError(err, "error sending message")
|
||||
return
|
||||
}
|
||||
|
||||
// Update status.
|
||||
// Update status as sent.
|
||||
m.UpdateMessageStatus(message.UUID, models.MessageStatusSent)
|
||||
|
||||
// Skip system user replies since we only update timestamps and SLA for human replies.
|
||||
@@ -393,20 +395,22 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID
|
||||
return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil)
|
||||
}
|
||||
|
||||
// Save to, cc and bcc in meta.
|
||||
to = stringutil.RemoveEmpty(to)
|
||||
cc = stringutil.RemoveEmpty(cc)
|
||||
bcc = stringutil.RemoveEmpty(bcc)
|
||||
metaMap["to"] = to
|
||||
if len(cc) > 0 {
|
||||
metaMap["cc"] = cc
|
||||
}
|
||||
if len(bcc) > 0 {
|
||||
metaMap["bcc"] = bcc
|
||||
}
|
||||
|
||||
var sourceID = ""
|
||||
if inboxRecord.Channel == "email" {
|
||||
switch inboxRecord.Channel {
|
||||
case inbox.ChannelEmail:
|
||||
// Add `to`, `cc`, and `bcc` recipients to meta map.
|
||||
to = stringutil.RemoveEmpty(to)
|
||||
cc = stringutil.RemoveEmpty(cc)
|
||||
bcc = stringutil.RemoveEmpty(bcc)
|
||||
if len(to) > 0 {
|
||||
metaMap["to"] = to
|
||||
}
|
||||
if len(cc) > 0 {
|
||||
metaMap["cc"] = cc
|
||||
}
|
||||
if len(bcc) > 0 {
|
||||
metaMap["bcc"] = bcc
|
||||
}
|
||||
if len(to) == 0 {
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil)
|
||||
}
|
||||
@@ -415,7 +419,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID
|
||||
m.lo.Error("error generating source message id", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil)
|
||||
}
|
||||
} else {
|
||||
case inbox.ChannelLiveChat:
|
||||
sourceID, err = stringutil.RandomAlphanumeric(35)
|
||||
if err != nil {
|
||||
m.lo.Error("error generating random source id", "error", err)
|
||||
@@ -468,8 +472,8 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
message.TextContent = stringutil.HTML2Text(message.Content)
|
||||
|
||||
// Insert Message.
|
||||
if err := m.q.InsertMessage.QueryRow(message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType,
|
||||
message.Private, message.ContentType, message.SourceID, message.Meta).Scan(&message.ID, &message.UUID, &message.CreatedAt); err != nil {
|
||||
if err := m.q.InsertMessage.Get(message, message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType,
|
||||
message.Private, message.ContentType, message.SourceID, message.Meta); err != nil {
|
||||
m.lo.Error("error inserting message in db", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil)
|
||||
}
|
||||
@@ -511,7 +515,10 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println("NAME", sender.FullName())
|
||||
|
||||
// Censor CSAT content before saving last message details.
|
||||
message.CensorCSATContent()
|
||||
|
||||
if slices.Contains([]string{models.MessageIncoming, models.MessageOutgoing}, message.Type) && !message.Private {
|
||||
conversationMeta["last_chat_message"] = map[string]any{
|
||||
"uuid": message.UUID,
|
||||
@@ -521,8 +528,8 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
"id": sender.ID,
|
||||
"first_name": sender.FirstName,
|
||||
"last_name": sender.LastName,
|
||||
"type": sender.Type,
|
||||
},
|
||||
"sender_type": message.SenderType,
|
||||
}
|
||||
lastInteractionAt = null.TimeFrom(message.CreatedAt)
|
||||
}
|
||||
@@ -534,8 +541,8 @@ func (m *Manager) InsertMessage(message *models.Message) error {
|
||||
"id": sender.ID,
|
||||
"first_name": sender.FirstName,
|
||||
"last_name": sender.LastName,
|
||||
"type": sender.Type,
|
||||
},
|
||||
"sender_type": message.SenderType,
|
||||
}
|
||||
conversationMetaB, err := json.Marshal(conversationMeta)
|
||||
if err != nil {
|
||||
@@ -669,31 +676,43 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// processIncomingMessage handles the insertion of an incoming message and
|
||||
// ProcessIncomingMessage handles the insertion of an incoming message and
|
||||
// associated contact. It finds or creates the contact, checks for existing
|
||||
// conversations, and creates a new conversation if necessary. It also
|
||||
// inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules.
|
||||
func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
// Find or create contact and set sender ID in message.
|
||||
if err := m.userStore.CreateContact(&in.Contact); err != nil {
|
||||
m.lo.Error("error upserting contact", "error", err)
|
||||
return err
|
||||
}
|
||||
in.Message.SenderID = in.Contact.ID
|
||||
func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Message, error) {
|
||||
var (
|
||||
isNewConversation = false
|
||||
conversationID int
|
||||
err error
|
||||
)
|
||||
|
||||
// Conversations exists for this message?
|
||||
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
|
||||
if err != nil && err != errConversationNotFound {
|
||||
return err
|
||||
}
|
||||
if conversationID > 0 {
|
||||
return nil
|
||||
}
|
||||
// Do channel specific processing.
|
||||
switch in.Channel {
|
||||
case inbox.ChannelEmail:
|
||||
// Find or create contact and set sender ID in message.
|
||||
if err := m.userStore.CreateContact(&in.Contact); err != nil {
|
||||
m.lo.Error("error upserting contact", "error", err)
|
||||
return models.Message{}, err
|
||||
}
|
||||
in.Message.SenderID = in.Contact.ID
|
||||
|
||||
// Find or create new conversation.
|
||||
isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
// Conversations exists for this message?
|
||||
conversationID, err = m.findConversationID([]string{in.Message.SourceID.String})
|
||||
if err != nil && err != errConversationNotFound {
|
||||
return models.Message{}, err
|
||||
}
|
||||
if conversationID > 0 {
|
||||
return models.Message{}, nil
|
||||
}
|
||||
|
||||
// Find or create new conversation.
|
||||
isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID)
|
||||
if err != nil {
|
||||
return models.Message{}, err
|
||||
}
|
||||
case inbox.ChannelLiveChat:
|
||||
// For live chat, a conversation is created before the message is processed. So nothing to do here.
|
||||
}
|
||||
|
||||
// Upload message attachments.
|
||||
@@ -704,56 +723,15 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
|
||||
// Insert message.
|
||||
if err = m.InsertMessage(&in.Message); err != nil {
|
||||
return err
|
||||
return models.Message{}, err
|
||||
}
|
||||
|
||||
// Evaluate automation rules & send webhook events.
|
||||
if isNewConversation {
|
||||
conversation, err := m.GetConversation(in.Message.ConversationID, "")
|
||||
if err == nil {
|
||||
m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation)
|
||||
m.automation.EvaluateNewConversationRules(conversation)
|
||||
}
|
||||
return nil
|
||||
// Process post-message hooks (automation rules, webhooks, SLA, etc.).
|
||||
if err := m.ProcessIncomingMessageHooks(in.Message.ConversationUUID, isNewConversation); err != nil {
|
||||
m.lo.Error("error processing incoming message hooks", "conversation_uuid", in.Message.ConversationUUID, "error", err)
|
||||
return models.Message{}, fmt.Errorf("processing incoming message hooks: %w", err)
|
||||
}
|
||||
|
||||
// Reopen conversation if it's not Open.
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching system user", "error", err)
|
||||
} else {
|
||||
if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil {
|
||||
m.lo.Error("error reopening conversation", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set waiting since timestamp, this gets cleared when agent replies to the conversation.
|
||||
now := time.Now()
|
||||
m.UpdateConversationWaitingSince(in.Message.ConversationUUID, &now)
|
||||
|
||||
// Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
|
||||
// This cycle continues for next response time SLA metric.
|
||||
conversation, err := m.GetConversation(in.Message.ConversationID, "")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err)
|
||||
} else {
|
||||
// Trigger automations on incoming message event.
|
||||
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming)
|
||||
|
||||
if conversation.SLAPolicyID.Int == 0 {
|
||||
m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation")
|
||||
return nil
|
||||
}
|
||||
if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) {
|
||||
m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err)
|
||||
} else if !deadline.IsZero() {
|
||||
m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int)
|
||||
m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339))
|
||||
// Clear next response met at timestamp as this event was just created.
|
||||
m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return in.Message, nil
|
||||
}
|
||||
|
||||
// MessageExists checks if a message with the given messageID exists.
|
||||
@@ -968,9 +946,13 @@ func (m *Manager) attachAttachmentsToMessage(message *models.Message) error {
|
||||
return err
|
||||
}
|
||||
attachment := attachment.Attachment{
|
||||
Name: media.Filename,
|
||||
Content: blob,
|
||||
Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String),
|
||||
Name: media.Filename,
|
||||
UUID: media.UUID,
|
||||
ContentType: media.ContentType,
|
||||
Content: blob,
|
||||
Size: media.Size,
|
||||
Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String),
|
||||
URL: m.mediaStore.GetURL(media.UUID),
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
@@ -1030,3 +1012,56 @@ func (m *Manager) getLatestMessage(conversationID int, typ []string, status []st
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing
|
||||
// for incoming messages. This allows other channels to insert messages first and then call this
|
||||
// function to trigger the necessary hooks.
|
||||
func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error {
|
||||
// Handle new conversation events.
|
||||
if isNewConversation {
|
||||
conversation, err := m.GetConversation(0, conversationUUID)
|
||||
if err == nil {
|
||||
m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation)
|
||||
m.automation.EvaluateNewConversationRules(conversation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reopen conversation if it's not Open.
|
||||
systemUser, err := m.userStore.GetSystemUser()
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching system user", "error", err)
|
||||
} else {
|
||||
if err := m.ReOpenConversation(conversationUUID, systemUser); err != nil {
|
||||
m.lo.Error("error reopening conversation", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set waiting since timestamp, this gets cleared when agent replies to the conversation.
|
||||
now := time.Now()
|
||||
m.UpdateConversationWaitingSince(conversationUUID, &now)
|
||||
|
||||
// Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met.
|
||||
// This cycle continues for next response time SLA metric.
|
||||
conversation, err := m.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
|
||||
} else {
|
||||
// Trigger automations on incoming message event.
|
||||
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming)
|
||||
|
||||
if conversation.SLAPolicyID.Int == 0 {
|
||||
m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation")
|
||||
return nil
|
||||
}
|
||||
if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) {
|
||||
m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err)
|
||||
} else if !deadline.IsZero() {
|
||||
m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int)
|
||||
m.BroadcastConversationUpdate(conversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339))
|
||||
// Clear next response met at timestamp as this event was just created.
|
||||
m.BroadcastConversationUpdate(conversationUUID, "next_response_met_at", nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/attachment"
|
||||
@@ -52,26 +53,31 @@ var (
|
||||
ContentTypeHTML = "html"
|
||||
)
|
||||
|
||||
type LastChatMessage struct {
|
||||
Content string `db:"content" json:"content"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
Author umodels.ChatUser `db:"author" json:"author"`
|
||||
}
|
||||
|
||||
type ChatConversation struct {
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Status string `db:"status" json:"status"`
|
||||
LastMessage string `db:"last_message" json:"last_message"`
|
||||
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
||||
LastMessageSenderFirstName string `db:"last_message_sender_first_name" json:"last_message_sender_first_name"`
|
||||
LastMessageSenderLastName string `db:"last_message_sender_last_name" json:"last_message_sender_last_name"`
|
||||
LastMessageSenderAvatarURL string `db:"last_message_sender_avatar_url" json:"last_message_sender_avatar_url"`
|
||||
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
|
||||
Assignee umodels.User `db:"assignee" json:"assignee"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Status string `db:"status" json:"status"`
|
||||
LastChatMessage LastChatMessage `db:"last_message" json:"last_message"`
|
||||
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
|
||||
Assignee umodels.ChatUser `db:"assignee" json:"assignee"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UUID string `json:"uuid"`
|
||||
Content string `json:"content"`
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Attachments attachment.Attachments `json:"attachments"`
|
||||
UUID string `json:"uuid"`
|
||||
Status string `json:"status"`
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Content string `json:"content"`
|
||||
TextContent string `json:"text_content"`
|
||||
Author umodels.ChatUser `json:"author"`
|
||||
Attachments attachment.Attachments `json:"attachments"`
|
||||
Meta json.RawMessage `json:"meta"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
@@ -118,6 +124,13 @@ type Conversation struct {
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
type IncomingMessage struct {
|
||||
Channel string
|
||||
Message Message
|
||||
Contact umodels.User
|
||||
InboxID int
|
||||
}
|
||||
|
||||
type ConversationParticipant struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
@@ -139,39 +152,38 @@ type NewConversationsStats struct {
|
||||
|
||||
// Message represents a message in a conversation
|
||||
type Message struct {
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
ConversationID int `db:"conversation_id" json:"conversation_id"`
|
||||
Content string `db:"content" json:"content"`
|
||||
TextContent string `db:"text_content" json:"text_content"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Private bool `db:"private" json:"private"`
|
||||
SourceID null.String `db:"source_id" json:"-"`
|
||||
SenderID int `db:"sender_id" json:"sender_id"`
|
||||
SenderType string `db:"sender_type" json:"sender_type"`
|
||||
InboxID int `db:"inbox_id" json:"-"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
Attachments attachment.Attachments `db:"attachments" json:"attachments"`
|
||||
ConversationUUID string `db:"conversation_uuid" json:"-"`
|
||||
From string `db:"from" json:"-"`
|
||||
Subject string `db:"subject" json:"-"`
|
||||
Channel string `db:"channel" json:"-"`
|
||||
To pq.StringArray `db:"to" json:"-"`
|
||||
CC pq.StringArray `db:"cc" json:"-"`
|
||||
BCC pq.StringArray `db:"bcc" json:"-"`
|
||||
References []string `json:"-"`
|
||||
InReplyTo string `json:"-"`
|
||||
Headers textproto.MIMEHeader `json:"-"`
|
||||
AltContent string `db:"-" json:"-"`
|
||||
Media []mmodels.Media `db:"-" json:"-"`
|
||||
IsCSAT bool `db:"-" json:"-"`
|
||||
// TODO: Figure if this field is needed or there's a better way.
|
||||
MessageReceiverID int `db:"-" json:"-"`
|
||||
Total int `db:"total" json:"-"`
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
ConversationID int `db:"conversation_id" json:"conversation_id"`
|
||||
Content string `db:"content" json:"content"`
|
||||
TextContent string `db:"text_content" json:"text_content"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Private bool `db:"private" json:"private"`
|
||||
SourceID null.String `db:"source_id" json:"-"`
|
||||
SenderID int `db:"sender_id" json:"sender_id"`
|
||||
SenderType string `db:"sender_type" json:"sender_type"`
|
||||
InboxID int `db:"inbox_id" json:"-"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
Attachments attachment.Attachments `db:"attachments" json:"attachments"`
|
||||
ConversationUUID string `db:"conversation_uuid" json:"-"`
|
||||
From string `db:"from" json:"-"`
|
||||
Subject string `db:"subject" json:"-"`
|
||||
Channel string `db:"channel" json:"-"`
|
||||
To pq.StringArray `db:"to" json:"-"`
|
||||
CC pq.StringArray `db:"cc" json:"-"`
|
||||
BCC pq.StringArray `db:"bcc" json:"-"`
|
||||
MessageReceiverID int `db:"message_receiver_id" json:"-"`
|
||||
References []string `json:"-"`
|
||||
InReplyTo string `json:"-"`
|
||||
Headers textproto.MIMEHeader `json:"-"`
|
||||
AltContent string `db:"-" json:"-"`
|
||||
Media []mmodels.Media `db:"-" json:"-"`
|
||||
IsCSAT bool `db:"-" json:"-"`
|
||||
Total int `db:"total" json:"-"`
|
||||
}
|
||||
|
||||
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.
|
||||
@@ -181,11 +193,40 @@ func (m *Message) CensorCSATContent() {
|
||||
return
|
||||
}
|
||||
if isCsat, _ := meta["is_csat"].(bool); isCsat {
|
||||
m.Content = "Please rate your experience with us"
|
||||
m.Content = "Please rate this conversation"
|
||||
m.TextContent = m.Content
|
||||
}
|
||||
}
|
||||
|
||||
// CensorCSATContentWithStatus redacts the content and adds submission status for CSAT messages.
|
||||
func (m *Message) CensorCSATContentWithStatus(csatSubmitted bool, csatUUID string, rating int, feedback string) {
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal([]byte(m.Meta), &meta); err != nil {
|
||||
return
|
||||
}
|
||||
if isCsat, _ := meta["is_csat"].(bool); isCsat {
|
||||
m.Content = "Please rate this conversation"
|
||||
m.TextContent = m.Content
|
||||
|
||||
// Add submission status and UUID to meta
|
||||
meta["csat_submitted"] = csatSubmitted
|
||||
meta["csat_uuid"] = csatUUID
|
||||
|
||||
// Add submitted rating and feedback if CSAT was submitted
|
||||
if csatSubmitted {
|
||||
if rating > 0 {
|
||||
meta["submitted_rating"] = rating
|
||||
}
|
||||
meta["submitted_feedback"] = feedback
|
||||
}
|
||||
|
||||
// Update the meta field
|
||||
if updatedMeta, err := json.Marshal(meta); err == nil {
|
||||
m.Meta = json.RawMessage(updatedMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasCSAT returns true if the message is a CSAT message.
|
||||
func (m *Message) HasCSAT() bool {
|
||||
var meta map[string]interface{}
|
||||
@@ -196,11 +237,35 @@ func (m *Message) HasCSAT() bool {
|
||||
return isCsat
|
||||
}
|
||||
|
||||
// IncomingMessage links a message with the contact information and inbox id.
|
||||
type IncomingMessage struct {
|
||||
Message Message
|
||||
Contact umodels.User
|
||||
InboxID int
|
||||
// ExtractCSATUUID extracts the CSAT UUID from the message content.
|
||||
func (m *Message) ExtractCSATUUID() string {
|
||||
if !m.HasCSAT() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract UUID from the CSAT URL in the message content
|
||||
// Pattern: <a href="http://localhost:8000/csat/3b68fa67-ad1a-4c5b-87eb-b262c420f43f">
|
||||
content := m.Content
|
||||
// Look for /csat/ followed by UUID pattern
|
||||
start := strings.Index(content, "/csat/")
|
||||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
start += 6 // Skip "/csat/"
|
||||
|
||||
// Find the end of UUID (36 characters)
|
||||
if len(content) < start+36 {
|
||||
return ""
|
||||
}
|
||||
|
||||
uuid := content[start : start+36]
|
||||
|
||||
// Basic validation - UUID should contain hyphens at positions 8, 13, 18, 23
|
||||
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
|
||||
return uuid
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
|
||||
@@ -222,24 +222,35 @@ LIMIT 10;
|
||||
|
||||
-- name: get-contact-chat-conversations
|
||||
SELECT
|
||||
c.created_at,
|
||||
c.uuid,
|
||||
COALESCE(c.meta->'last_chat_message'->>'text_content', '') as last_message,
|
||||
COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as last_message_at,
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS last_message_sender_first_name,
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS last_message_sender_last_name,
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS last_message_sender_avatar_url,
|
||||
LEAST(10, COUNT(unread.id)) AS unread_message_count
|
||||
FROM conversations c
|
||||
COALESCE(c.meta->'last_chat_message'->>'text_content', '') as "last_message.content",
|
||||
COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as "last_message.created_at",
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'id', '') AS "last_message.author.id",
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS "last_message.author.first_name",
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS "last_message.author.last_name",
|
||||
COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS "last_message.author.avatar_url",
|
||||
LEAST(10, COUNT(unread.id)) AS unread_message_count,
|
||||
COALESCE(au.availability_status::TEXT, '') as "assignee.availability_status",
|
||||
au.avatar_url as "assignee.avatar_url",
|
||||
COALESCE(au.first_name, '') as "assignee.first_name",
|
||||
COALESCE(au.id, 0) as "assignee.id",
|
||||
COALESCE(au.last_name, '') as "assignee.last_name",
|
||||
COALESCE(au.type::TEXT, '') as "assignee.type"
|
||||
FROM conversations c inner join inboxes inb on c.inbox_id = inb.id
|
||||
LEFT JOIN users au ON c.assigned_user_id = au.id
|
||||
LEFT JOIN conversation_messages unread ON unread.conversation_id = c.id
|
||||
AND unread.created_at > c.contact_last_seen_at
|
||||
AND unread.type IN ('incoming', 'outgoing') AND unread.private = false
|
||||
WHERE c.contact_id = $1
|
||||
GROUP BY c.id, c.uuid,
|
||||
WHERE c.contact_id = $1 AND inb.channel = 'livechat' AND inb.deleted_at IS NULL AND inb.enabled = true
|
||||
GROUP BY c.id, c.uuid, c.created_at,
|
||||
c.meta->'last_chat_message'->>'text_content',
|
||||
(c.meta->'last_chat_message'->>'created_at')::timestamptz,
|
||||
c.meta->'last_chat_message'->'sender'->>'id',
|
||||
c.meta->'last_chat_message'->'sender'->>'first_name',
|
||||
c.meta->'last_chat_message'->'sender'->>'last_name',
|
||||
c.meta->'last_chat_message'->'sender'->>'avatar_url'
|
||||
c.meta->'last_chat_message'->'sender'->>'avatar_url',
|
||||
au.availability_status, au.avatar_url, au.first_name, au.id, au.last_name, au.type
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
@@ -473,15 +484,19 @@ SELECT
|
||||
m.private,
|
||||
m.status,
|
||||
m.content,
|
||||
m.text_content,
|
||||
m.sender_type,
|
||||
m.conversation_id,
|
||||
m.content_type,
|
||||
m.source_id,
|
||||
m.meta,
|
||||
ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc,
|
||||
ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc,
|
||||
ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
|
||||
c.inbox_id,
|
||||
c.uuid as conversation_uuid,
|
||||
c.subject
|
||||
c.subject,
|
||||
c.contact_id as message_receiver_id
|
||||
FROM conversation_messages m
|
||||
INNER JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.status = 'pending' AND m.type = 'outgoing' AND m.private = false
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,37 @@ func (m *Manager) BroadcastConversationUpdate(conversationUUID, prop string, val
|
||||
m.broadcastToUsers([]int{}, message)
|
||||
}
|
||||
|
||||
// BroadcastTypingToConversation broadcasts typing status to all subscribers of a conversation.
|
||||
// Set broadcastToWidgets to false when the typing event originates from a widget client to avoid echo.
|
||||
func (m *Manager) BroadcastTypingToConversation(conversationUUID string, isTyping bool, broadcastToWidgets bool) {
|
||||
message := wsmodels.Message{
|
||||
Type: wsmodels.MessageTypeTyping,
|
||||
Data: map[string]interface{}{
|
||||
"conversation_uuid": conversationUUID,
|
||||
"is_typing": isTyping,
|
||||
},
|
||||
}
|
||||
|
||||
messageBytes, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
m.lo.Error("error marshalling typing WS message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Always broadcast to agent clients (main app WebSocket clients)
|
||||
m.wsHub.BroadcastTypingToAllConversationClients(conversationUUID, messageBytes)
|
||||
|
||||
// Broadcast to widget clients (customers) only if this typing event comes from agents
|
||||
if broadcastToWidgets {
|
||||
m.broadcastTypingToWidgetClients(conversationUUID, isTyping)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTypingToWidgetClientsOnly broadcasts typing status only to widget clients.
|
||||
func (m *Manager) BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool) {
|
||||
m.broadcastTypingToWidgetClients(conversationUUID, isTyping)
|
||||
}
|
||||
|
||||
// broadcastToUsers broadcasts a message to a list of users, if the list is empty it broadcasts to all users.
|
||||
func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) {
|
||||
messageBytes, err := json.Marshal(message)
|
||||
@@ -63,3 +95,25 @@ func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) {
|
||||
Users: userIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastTypingToWidgetClients broadcasts typing status to widget clients (customers) for a conversation.
|
||||
func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTyping bool) {
|
||||
// Get the conversation to find its inbox ID
|
||||
conversation, err := m.GetConversation(0, conversationUUID)
|
||||
if err != nil {
|
||||
m.lo.Error("error getting conversation for widget typing broadcast", "error", err, "conversation_uuid", conversationUUID)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the inbox
|
||||
inboxInstance, err := m.inboxStore.Get(conversation.InboxID)
|
||||
if err != nil {
|
||||
m.lo.Error("error getting inbox for widget typing broadcast", "error", err, "inbox_id", conversation.InboxID)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a livechat inbox and broadcast typing status
|
||||
if liveChatInbox, ok := inboxInstance.(*livechat.LiveChat); ok {
|
||||
liveChatInbox.BroadcastTypingToClients(conversationUUID, isTyping)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
|
||||
return err
|
||||
}
|
||||
|
||||
if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() {
|
||||
// Check if CSAT has already been submitted (response timestamp exists)
|
||||
if !csat.ResponseTimestamp.IsZero() {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list
|
||||
// Expects trustedDomains to be a list of domain strings, which can include wildcards.
|
||||
// Like "*.example.com" or "example.com".
|
||||
func IsOriginTrusted(origin string, trustedDomains []string) bool {
|
||||
if len(trustedDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
originHost, originPort := parseHostPort(origin)
|
||||
if originHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, trusted := range trustedDomains {
|
||||
trustedHost, trustedPort := parseTrustedDomain(trusted)
|
||||
if portMatches(originPort, trustedPort) && hostMatches(originHost, trustedHost) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseHostPort extracts host and port from origin URL
|
||||
func parseHostPort(origin string) (host, port string) {
|
||||
u, err := url.Parse(strings.ToLower(origin))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
host, port, _ = net.SplitHostPort(u.Host)
|
||||
if host == "" {
|
||||
host = u.Host
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
// parseTrustedDomain extracts host and port from trusted domain entry
|
||||
func parseTrustedDomain(domain string) (host, port string) {
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
if strings.HasPrefix(domain, "http://") || strings.HasPrefix(domain, "https://") {
|
||||
u, err := url.Parse(domain)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
host, port, _ = net.SplitHostPort(u.Host)
|
||||
if host == "" {
|
||||
host = u.Host
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
// Handle non-URL patterns (wildcards/domains)
|
||||
host, port, _ = net.SplitHostPort(domain)
|
||||
if host == "" {
|
||||
host = domain
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
// portMatches checks if ports are compatible
|
||||
func portMatches(originPort, trustedPort string) bool {
|
||||
if trustedPort == "" || trustedPort == originPort {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hostMatches checks if host matches trusted pattern
|
||||
func hostMatches(origin, trusted string) bool {
|
||||
if trusted == origin {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trusted, "*.") {
|
||||
base := trusted[2:]
|
||||
return origin == base || strings.HasSuffix(origin, "."+base)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -340,6 +340,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
|
||||
return fmt.Errorf("marshalling meta: %w", err)
|
||||
}
|
||||
incomingMsg := models.IncomingMessage{
|
||||
Channel: ChannelEmail,
|
||||
Message: models.Message{
|
||||
Channel: e.Channel(),
|
||||
SenderType: models.SenderTypeContact,
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
// Config holds the live chat inbox configuration.
|
||||
type Config struct {
|
||||
BrandName string `json:"brand_name"`
|
||||
DarkMode bool `json:"dark_mode"`
|
||||
Language string `json:"language"`
|
||||
Users struct {
|
||||
AllowStartConversation bool `json:"allow_start_conversation"`
|
||||
@@ -67,6 +68,7 @@ type Config struct {
|
||||
IntroductionMessage string `json:"introduction_message"`
|
||||
ShowOfficeHoursInChat bool `json:"show_office_hours_in_chat"`
|
||||
ShowOfficeHoursAfterAssignment bool `json:"show_office_hours_after_assignment"`
|
||||
ChatReplyExpectationMessage string `json:"chat_reply_expectation_message"`
|
||||
}
|
||||
|
||||
// Client represents a connected chat client
|
||||
@@ -137,38 +139,49 @@ func (lc *LiveChat) Send(message models.Message) error {
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
// Set `content` in all attachments to `null` as attachments are sent with URLs and live chat uses URLs to fetch the content.
|
||||
for i := range message.Attachments {
|
||||
if message.Attachments[i].Content != nil {
|
||||
message.Attachments[i].Content = nil
|
||||
}
|
||||
}
|
||||
|
||||
messageData := map[string]any{
|
||||
"type": "new_message",
|
||||
"data": map[string]any{
|
||||
"created_at": message.CreatedAt.Format(time.RFC3339),
|
||||
"conversation_uuid": message.ConversationUUID,
|
||||
"uuid": message.UUID,
|
||||
"content": message.Content,
|
||||
"text_content": message.TextContent,
|
||||
"sender_type": message.SenderType,
|
||||
"sender_name": sender.FullName(),
|
||||
"status": message.Status,
|
||||
"data": models.ChatMessage{
|
||||
UUID: message.UUID,
|
||||
ConversationUUID: message.ConversationUUID,
|
||||
CreatedAt: message.CreatedAt,
|
||||
Content: message.Content,
|
||||
TextContent: message.TextContent,
|
||||
Meta: message.Meta,
|
||||
Author: umodels.ChatUser{
|
||||
ID: message.SenderID,
|
||||
FirstName: sender.FirstName,
|
||||
LastName: sender.LastName,
|
||||
AvatarURL: sender.AvatarURL,
|
||||
AvailabilityStatus: sender.AvailabilityStatus,
|
||||
Type: sender.Type,
|
||||
},
|
||||
Attachments: message.Attachments,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert messageData to JSON
|
||||
// Marshal and send to client's channel.
|
||||
messageJSON, err := json.Marshal(messageData)
|
||||
if err != nil {
|
||||
lc.lo.Error("failed to marshal message data", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send the message to the client's channel
|
||||
select {
|
||||
case client.Channel <- messageJSON:
|
||||
lc.lo.Info("message sent to live chat client", "client_id", client.ID, "message_id", message.UUID)
|
||||
default:
|
||||
lc.lo.Warn("client channel full, dropping message", "client_id", client.ID, "message_id", message.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
lc.lo.Debug("client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID)
|
||||
lc.lo.Debug("websocket client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID)
|
||||
return ErrClientNotConnected
|
||||
}
|
||||
}
|
||||
@@ -192,7 +205,7 @@ func (lc *LiveChat) Channel() string {
|
||||
}
|
||||
|
||||
// AddClient adds a new client to the live chat session.
|
||||
func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) {
|
||||
func (lc *LiveChat) AddClient(userID string) (*Client, error) {
|
||||
lc.clientsMutex.Lock()
|
||||
defer lc.clientsMutex.Unlock()
|
||||
|
||||
@@ -209,8 +222,6 @@ func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error)
|
||||
|
||||
// Add the client to the clients map.
|
||||
lc.clients[userID] = append(lc.clients[userID], client)
|
||||
|
||||
lc.lo.Info("client added to live chat", "client_id", userID, "conversation_uuid", conversationUUID)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -235,3 +246,36 @@ func (lc *LiveChat) RemoveClient(c *Client) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTypingToClients broadcasts typing status to all connected widget clients for a conversation.
|
||||
func (lc *LiveChat) BroadcastTypingToClients(conversationUUID string, isTyping bool) {
|
||||
lc.clientsMutex.RLock()
|
||||
defer lc.clientsMutex.RUnlock()
|
||||
|
||||
// Create typing status message for widget clients
|
||||
typingMessage := map[string]interface{}{
|
||||
"type": "typing",
|
||||
"data": map[string]interface{}{
|
||||
"conversation_uuid": conversationUUID,
|
||||
"is_typing": isTyping,
|
||||
},
|
||||
}
|
||||
|
||||
messageJSON, err := json.Marshal(typingMessage)
|
||||
if err != nil {
|
||||
lc.lo.Error("failed to marshal typing message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast to all connected clients
|
||||
for userID, clients := range lc.clients {
|
||||
for _, client := range clients {
|
||||
select {
|
||||
case client.Channel <- messageJSON:
|
||||
lc.lo.Debug("typing status sent to widget client", "user_id", userID, "client_id", client.ID, "conversation_uuid", conversationUUID, "is_typing", isTyping)
|
||||
default:
|
||||
lc.lo.Warn("client channel full, dropping typing message", "user_id", userID, "client_id", client.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-4
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
@@ -35,19 +36,29 @@ type Store interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
// SignedURLStore defines the interface for stores that support signed URLs.
|
||||
// This is optional and only implemented by stores that need signed URL functionality (like fs).
|
||||
type SignedURLStore interface {
|
||||
Store
|
||||
GetSignedURL(name string, expiresAt time.Time, secret []byte) string
|
||||
VerifySignature(name, signature string, expiresAt time.Time, secret []byte) bool
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store Store
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
queries queries
|
||||
secret string
|
||||
}
|
||||
|
||||
// Opts provides options for configuring the Manager.
|
||||
type Opts struct {
|
||||
Store Store
|
||||
Lo *logf.Logger
|
||||
DB *sqlx.DB
|
||||
I18n *i18n.I18n
|
||||
Store Store
|
||||
Lo *logf.Logger
|
||||
DB *sqlx.DB
|
||||
I18n *i18n.I18n
|
||||
Secret string
|
||||
}
|
||||
|
||||
// New initializes and returns a new Manager instance for handling media operations.
|
||||
@@ -61,6 +72,7 @@ func New(opt Opts) (*Manager, error) {
|
||||
lo: opt.Lo,
|
||||
i18n: opt.I18n,
|
||||
queries: q,
|
||||
secret: opt.Secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -217,3 +229,49 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSignedURL returns a signed URL for accessing a media file with expiration.
|
||||
// This delegates to the store if it supports signed URLs (like fs), otherwise returns the normal URL.
|
||||
func (m *Manager) GetSignedURL(name string, expiresAt time.Time) string {
|
||||
// Check if the store supports signed URLs
|
||||
if signedStore, ok := m.store.(SignedURLStore); ok {
|
||||
return signedStore.GetSignedURL(name, expiresAt, []byte(m.secret))
|
||||
}
|
||||
// Fallback to regular URL for stores that handle signing internally (like S3)
|
||||
return m.store.GetURL(name)
|
||||
}
|
||||
|
||||
// VerifySignature verifies a signed URL signature using the request parameters.
|
||||
// This is used by middleware to verify widget media access.
|
||||
func (m *Manager) VerifySignature(r *fastglue.Request) error {
|
||||
uuid := r.RequestCtx.UserValue("uuid")
|
||||
if uuid == nil {
|
||||
return fmt.Errorf("missing uuid parameter")
|
||||
}
|
||||
|
||||
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
|
||||
expiresStr := string(r.RequestCtx.QueryArgs().Peek("expires"))
|
||||
|
||||
if signature == "" || expiresStr == "" {
|
||||
return fmt.Errorf("missing signature or expires parameter")
|
||||
}
|
||||
|
||||
// Parse expiration time
|
||||
var expires int64
|
||||
if _, err := fmt.Sscanf(expiresStr, "%d", &expires); err != nil {
|
||||
return fmt.Errorf("invalid expires parameter: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Unix(expires, 0)
|
||||
|
||||
// Check if store supports signature verification
|
||||
if signedStore, ok := m.store.(SignedURLStore); ok {
|
||||
if !signedStore.VerifySignature(uuid.(string), signature, expiresAt, []byte(m.secret)) {
|
||||
return fmt.Errorf("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For stores that don't support signing (like S3), always allow
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/volatiletech/null/v9"
|
||||
@@ -15,17 +16,18 @@ const (
|
||||
|
||||
// Media represents an uploaded object.
|
||||
type Media struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Model null.String `db:"model_type" json:"-"`
|
||||
ModelID null.Int `db:"model_id" json:"-"`
|
||||
Size int `db:"size" json:"size"`
|
||||
Store string `db:"store" json:"store"`
|
||||
Disposition null.String `db:"disposition" json:"disposition"`
|
||||
URL string `json:"url"`
|
||||
ContentID string `json:"-"`
|
||||
Content []byte `json:"-"`
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Model null.String `db:"model_type" json:"-"`
|
||||
ModelID null.Int `db:"model_id" json:"-"`
|
||||
Size int `db:"size" json:"size"`
|
||||
Store string `db:"store" json:"store"`
|
||||
Disposition null.String `db:"disposition" json:"disposition"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
URL string `json:"url"`
|
||||
ContentID string `json:"-"`
|
||||
Content []byte `json:"-"`
|
||||
}
|
||||
|
||||
@@ -8,13 +8,27 @@ import (
|
||||
|
||||
// V0_8_0 updates the database schema to v0.8.0.
|
||||
func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
// Add 'livechat' to the channels enum
|
||||
_, err := db.Exec(`
|
||||
ALTER TYPE channels ADD VALUE IF NOT EXISTS 'livechat';
|
||||
// Add 'livechat' to the channels enum if not already present
|
||||
var exists bool
|
||||
err := db.Get(&exists, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum
|
||||
WHERE enumlabel = 'livechat'
|
||||
AND enumtypid = (
|
||||
SELECT oid FROM pg_type WHERE typname = 'channels'
|
||||
)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
_, err = db.Exec(`ALTER TYPE channels ADD VALUE 'livechat'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the foreign key constraint and column from conversations table first
|
||||
_, err = db.Exec(`
|
||||
@@ -64,5 +78,34 @@ func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Beginx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmts := []string{
|
||||
/* ── drop index for e‑mail uniqueness and add seperate indexes for type of user ── */
|
||||
`DROP INDEX IF EXISTS index_unique_users_on_email_and_type_when_deleted_at_is_null`,
|
||||
|
||||
/* ── add separate indexes for type of user, this excludes `visitor` type as there can be multiple visitors with the same email ── */
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
index_unique_users_on_email_when_type_is_contact
|
||||
ON users(email)
|
||||
WHERE type = 'contact' AND deleted_at IS NULL`,
|
||||
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS
|
||||
index_unique_users_on_email_when_type_is_agent
|
||||
ON users(email)
|
||||
WHERE type = 'agent' AND deleted_at IS NULL`,
|
||||
}
|
||||
|
||||
for _, q := range stmts {
|
||||
if _, err = tx.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ type User struct {
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
Password null.String `db:"password" json:"-"`
|
||||
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
|
||||
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles"`
|
||||
@@ -63,6 +63,17 @@ type User struct {
|
||||
Total int `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ChatUser is a user with limited fields for live chat.
|
||||
type ChatUser struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
Type string `db:"type" json:"type"`
|
||||
ActiveAt null.Time `db:"active_at" json:"active_at"`
|
||||
}
|
||||
|
||||
type Note struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
@@ -63,7 +63,10 @@ FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
|
||||
WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND ($1 = 0 OR u.id = $1)
|
||||
AND ($2 = '' OR u.email = $2)
|
||||
AND ($3 = '' OR u.type::text = $3)
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: set-user-password
|
||||
@@ -152,16 +155,14 @@ RETURNING user_id;
|
||||
-- name: insert-contact
|
||||
INSERT INTO users (email, type, first_name, last_name, "password", avatar_url)
|
||||
VALUES ($1, 'contact', $2, $3, $4, $5)
|
||||
ON CONFLICT (email, type) WHERE deleted_at IS NULL
|
||||
ON CONFLICT (email) WHERE type = 'contact' AND deleted_at IS NULL
|
||||
DO UPDATE SET updated_at = now()
|
||||
RETURNING id;
|
||||
|
||||
-- name: insert-visitor
|
||||
INSERT INTO users (email, type, first_name, last_name, "password", avatar_url)
|
||||
VALUES ($1, 'visitor', $2, $3, $4, $5)
|
||||
ON CONFLICT (email, type) WHERE deleted_at IS NULL
|
||||
DO UPDATE SET updated_at = now()
|
||||
RETURNING id;
|
||||
INSERT INTO users (email, type, first_name, last_name)
|
||||
VALUES ($1, 'visitor', $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: update-last-login-at
|
||||
UPDATE users
|
||||
|
||||
@@ -115,7 +115,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
if err := u.verifyPassword(password, user.Password); err != nil {
|
||||
if err := u.verifyPassword(password, user.Password.String); err != nil {
|
||||
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
|
||||
}
|
||||
return user, nil
|
||||
@@ -151,6 +151,10 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin
|
||||
|
||||
// Get retrieves an user by ID or email.
|
||||
func (u *Manager) Get(id int, email, type_ string) (models.User, error) {
|
||||
if id == 0 && email == "" {
|
||||
return models.User{}, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, id, email, type_); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -11,12 +11,6 @@ import (
|
||||
|
||||
// CreateVisitor creates a new visitor user.
|
||||
func (u *Manager) CreateVisitor(user *models.User) error {
|
||||
password, err := u.generatePassword()
|
||||
if err != nil {
|
||||
u.lo.Error("generating password", "error", err)
|
||||
return fmt.Errorf("generating password: %w", err)
|
||||
}
|
||||
|
||||
// Normalize email address.
|
||||
user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid)
|
||||
|
||||
@@ -25,7 +19,7 @@ func (u *Manager) CreateVisitor(user *models.User) error {
|
||||
user.FirstName = h.Haikunate()
|
||||
}
|
||||
|
||||
if err := u.q.InsertVisitor.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL).Scan(&user.ID); err != nil {
|
||||
if err := u.q.InsertVisitor.Get(user, user.Email, user.FirstName, user.LastName); err != nil {
|
||||
u.lo.Error("error inserting contact", "error", err)
|
||||
return fmt.Errorf("insert contact: %w", err)
|
||||
}
|
||||
|
||||
+78
-1
@@ -100,7 +100,84 @@ func (c *Client) processIncomingMessage(data []byte) {
|
||||
c.SendMessage([]byte("pong"), websocket.TextMessage)
|
||||
return
|
||||
}
|
||||
c.SendError("unknown incoming message type")
|
||||
|
||||
// Try to parse as JSON message
|
||||
var msg models.Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
c.SendError("invalid message format")
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case models.MessageTypeConversationSubscribe:
|
||||
c.handleConversationSubscribe(msg.Data)
|
||||
case models.MessageTypeTyping:
|
||||
c.handleTyping(msg.Data)
|
||||
default:
|
||||
c.SendError("unknown message type")
|
||||
}
|
||||
}
|
||||
|
||||
// handleConversationSubscribe handles conversation subscription requests.
|
||||
func (c *Client) handleConversationSubscribe(data interface{}) {
|
||||
// Convert the data to JSON and then unmarshal to ConversationSubscribe
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
c.SendError("invalid subscription data")
|
||||
return
|
||||
}
|
||||
|
||||
var subscribeMsg models.ConversationSubscribe
|
||||
if err := json.Unmarshal(dataBytes, &subscribeMsg); err != nil {
|
||||
c.SendError("invalid subscription format")
|
||||
return
|
||||
}
|
||||
|
||||
if subscribeMsg.ConversationUUID == "" {
|
||||
c.SendError("conversation_uuid is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to the conversation using the Hub
|
||||
c.Hub.SubscribeToConversation(c, subscribeMsg.ConversationUUID)
|
||||
|
||||
// Send confirmation back to client
|
||||
response := models.Message{
|
||||
Type: models.MessageTypeConversationSubscribed,
|
||||
Data: map[string]string{
|
||||
"conversation_uuid": subscribeMsg.ConversationUUID,
|
||||
},
|
||||
}
|
||||
|
||||
responseBytes, _ := json.Marshal(response)
|
||||
c.SendMessage(responseBytes, websocket.TextMessage)
|
||||
}
|
||||
|
||||
// handleTyping handles typing indicator messages.
|
||||
func (c *Client) handleTyping(data interface{}) {
|
||||
// Convert the data to JSON and then unmarshal to TypingMessage
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
c.SendError("invalid typing data")
|
||||
return
|
||||
}
|
||||
|
||||
var typingMsg models.TypingMessage
|
||||
if err := json.Unmarshal(dataBytes, &typingMsg); err != nil {
|
||||
c.SendError("invalid typing format")
|
||||
return
|
||||
}
|
||||
|
||||
if typingMsg.ConversationUUID == "" {
|
||||
c.SendError("conversation_uuid is required for typing")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the user ID from the client
|
||||
typingMsg.UserID = c.ID
|
||||
|
||||
// Broadcast typing status to all subscribers of this conversation (except sender)
|
||||
c.Hub.BroadcastTypingToConversation(typingMsg.ConversationUUID, typingMsg, c)
|
||||
}
|
||||
|
||||
// close closes the client connection.
|
||||
|
||||
@@ -7,6 +7,9 @@ const (
|
||||
MessageTypeNewMessage = "new_message"
|
||||
MessageTypeNewConversation = "new_conversation"
|
||||
MessageTypeError = "error"
|
||||
MessageTypeConversationSubscribe = "conversation_subscribe"
|
||||
MessageTypeConversationSubscribed = "conversation_subscribed"
|
||||
MessageTypeTyping = "typing"
|
||||
)
|
||||
|
||||
// WSMessage represents a WS message.
|
||||
@@ -26,3 +29,15 @@ type BroadcastMessage struct {
|
||||
Data []byte `json:"data"`
|
||||
Users []int `json:"users"`
|
||||
}
|
||||
|
||||
// ConversationSubscribe represents a conversation subscription message.
|
||||
type ConversationSubscribe struct {
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
}
|
||||
|
||||
// TypingMessage represents a typing indicator message.
|
||||
type TypingMessage struct {
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
IsTyping bool `json:"is_typing"`
|
||||
UserID int `json:"user_id"`
|
||||
}
|
||||
|
||||
+125
-4
@@ -2,6 +2,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
@@ -14,22 +15,40 @@ type Hub struct {
|
||||
clients map[int][]*Client
|
||||
clientsMutex sync.RWMutex
|
||||
|
||||
userStore userStore
|
||||
// Conversation UUID to clients map for faster conversation broadcasting
|
||||
conversationClients map[string][]*Client
|
||||
conversationClientsMutex sync.RWMutex
|
||||
|
||||
userStore userStore
|
||||
conversationStore conversationStore
|
||||
}
|
||||
|
||||
type userStore interface {
|
||||
UpdateLastActive(userID int) error
|
||||
}
|
||||
|
||||
type conversationStore interface {
|
||||
BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool)
|
||||
}
|
||||
|
||||
// NewHub creates a new websocket hub.
|
||||
func NewHub(userStore userStore) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[int][]*Client, 10000),
|
||||
clientsMutex: sync.RWMutex{},
|
||||
userStore: userStore,
|
||||
clients: make(map[int][]*Client, 10000),
|
||||
clientsMutex: sync.RWMutex{},
|
||||
conversationClients: make(map[string][]*Client),
|
||||
conversationClientsMutex: sync.RWMutex{},
|
||||
userStore: userStore,
|
||||
// To be set later via conversationStore.
|
||||
conversationStore: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// SetConversationStore sets the conversation store for cross-broadcasting.
|
||||
func (h *Hub) SetConversationStore(manager conversationStore) {
|
||||
h.conversationStore = manager
|
||||
}
|
||||
|
||||
// AddClient adds a new client to the hub.
|
||||
func (h *Hub) AddClient(client *Client) {
|
||||
h.clientsMutex.Lock()
|
||||
@@ -41,6 +60,12 @@ func (h *Hub) AddClient(client *Client) {
|
||||
func (h *Hub) RemoveClient(client *Client) {
|
||||
h.clientsMutex.Lock()
|
||||
defer h.clientsMutex.Unlock()
|
||||
|
||||
// Remove from all conversation subscriptions
|
||||
h.conversationClientsMutex.Lock()
|
||||
h.removeClientFromAllConversations(client)
|
||||
h.conversationClientsMutex.Unlock()
|
||||
|
||||
if clients, ok := h.clients[client.ID]; ok {
|
||||
for i, c := range clients {
|
||||
if c == client {
|
||||
@@ -74,3 +99,99 @@ func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToConversation subscribes a client to a conversation.
|
||||
func (h *Hub) SubscribeToConversation(client *Client, conversationUUID string) {
|
||||
h.conversationClientsMutex.Lock()
|
||||
defer h.conversationClientsMutex.Unlock()
|
||||
|
||||
// Unsubscribe from previous conversation if any
|
||||
h.removeClientFromAllConversations(client)
|
||||
|
||||
// Subscribe to new conversation
|
||||
h.conversationClients[conversationUUID] = append(h.conversationClients[conversationUUID], client)
|
||||
}
|
||||
|
||||
// UnsubscribeFromConversation unsubscribes a client from a conversation.
|
||||
func (h *Hub) UnsubscribeFromConversation(client *Client, conversationUUID string) {
|
||||
h.conversationClientsMutex.Lock()
|
||||
defer h.conversationClientsMutex.Unlock()
|
||||
h.unsubscribeFromConversationUnsafe(client, conversationUUID)
|
||||
}
|
||||
|
||||
// unsubscribeFromConversationUnsafe removes a client from conversation subscription without locking.
|
||||
// Must be called with conversationClientsMutex held.
|
||||
func (h *Hub) unsubscribeFromConversationUnsafe(client *Client, conversationUUID string) {
|
||||
if clients, ok := h.conversationClients[conversationUUID]; ok {
|
||||
for i, c := range clients {
|
||||
if c == client {
|
||||
h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...)
|
||||
if len(h.conversationClients[conversationUUID]) == 0 {
|
||||
delete(h.conversationClients, conversationUUID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeClientFromAllConversations removes a client from all conversation subscriptions.
|
||||
// Must be called with conversationClientsMutex held.
|
||||
func (h *Hub) removeClientFromAllConversations(client *Client) {
|
||||
for conversationUUID, clients := range h.conversationClients {
|
||||
for i, c := range clients {
|
||||
if c == client {
|
||||
h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...)
|
||||
if len(h.conversationClients[conversationUUID]) == 0 {
|
||||
delete(h.conversationClients, conversationUUID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastToConversation broadcasts a message to all clients subscribed to a specific conversation.
|
||||
func (h *Hub) BroadcastToConversation(conversationUUID string, data []byte) {
|
||||
h.conversationClientsMutex.RLock()
|
||||
defer h.conversationClientsMutex.RUnlock()
|
||||
|
||||
for _, client := range h.conversationClients[conversationUUID] {
|
||||
client.SendMessage(data, websocket.TextMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTypingToConversation broadcasts typing status to all clients subscribed to a conversation except the sender.
|
||||
func (h *Hub) BroadcastTypingToConversation(conversationUUID string, typingMsg models.TypingMessage, sender *Client) {
|
||||
h.conversationClientsMutex.RLock()
|
||||
defer h.conversationClientsMutex.RUnlock()
|
||||
|
||||
message := models.Message{
|
||||
Type: models.MessageTypeTyping,
|
||||
Data: typingMsg,
|
||||
}
|
||||
|
||||
messageBytes, _ := json.Marshal(message)
|
||||
|
||||
for _, client := range h.conversationClients[conversationUUID] {
|
||||
// Don't send typing indicator back to the sender.
|
||||
if client != sender {
|
||||
client.SendMessage(messageBytes, websocket.TextMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Also broadcast to widget clients since this is an agent typing.
|
||||
if h.conversationStore != nil {
|
||||
h.conversationStore.BroadcastTypingToWidgetClientsOnly(conversationUUID, typingMsg.IsTyping)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastTypingToAllConversationClients broadcasts typing status to all clients subscribed to a conversation.
|
||||
func (h *Hub) BroadcastTypingToAllConversationClients(conversationUUID string, data []byte) {
|
||||
h.conversationClientsMutex.RLock()
|
||||
defer h.conversationClientsMutex.RUnlock()
|
||||
|
||||
for _, client := range h.conversationClients[conversationUUID] {
|
||||
client.SendMessage(data, websocket.TextMessage)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -151,8 +151,12 @@ CREATE TABLE users (
|
||||
CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140),
|
||||
CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140)
|
||||
);
|
||||
CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_contact
|
||||
ON users(email)
|
||||
WHERE type = 'contact' AND deleted_at IS NULL;
|
||||
CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_agent
|
||||
ON users(email)
|
||||
WHERE type = 'agent' AND deleted_at IS NULL;
|
||||
CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops);
|
||||
CREATE INDEX index_users_on_api_key ON users(api_key);
|
||||
|
||||
|
||||
+140
-19
@@ -6,11 +6,11 @@
|
||||
'use strict';
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (window.LibreDeskWidget) {
|
||||
if (window.LibredeskWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
class LibreDeskWidget {
|
||||
class LibredeskWidget {
|
||||
constructor(config = {}) {
|
||||
// Validate required config
|
||||
if (!config.baseUrl) {
|
||||
@@ -23,9 +23,12 @@
|
||||
this.config = config;
|
||||
this.iframe = null;
|
||||
this.toggleButton = null;
|
||||
this.widgetButtonWrapper = null;
|
||||
this.unreadBadge = null;
|
||||
this.isChatVisible = false;
|
||||
this.widgetSettings = null;
|
||||
|
||||
this.unreadCount = 0;
|
||||
this.isMobile = window.innerWidth <= 600;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -33,18 +36,25 @@
|
||||
try {
|
||||
await this.fetchWidgetSettings();
|
||||
this.createElements();
|
||||
this.setLauncherPosition();
|
||||
this.iframe.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
this.sendMobileState();
|
||||
}, 2000);
|
||||
});
|
||||
this.setupMobileDetection();
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize LibreDesk Widget:', error);
|
||||
console.error('Failed to initialize Libredesk Widget:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchWidgetSettings () {
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings?inbox_id=${this.config.inboxID}`);
|
||||
const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings/launcher?inbox_id=${this.config.inboxID}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
throw new Error(`Error fetching widget settings. Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -94,6 +104,39 @@
|
||||
this.toggleButton.appendChild(icon);
|
||||
}
|
||||
|
||||
// Create unread badge
|
||||
this.unreadBadge = document.createElement('div');
|
||||
this.unreadBadge.style.cssText = `
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
border: 2px solid white;
|
||||
box-sizing: border-box;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const widgetButtonWrapper = document.createElement('div');
|
||||
widgetButtonWrapper.style.cssText = `
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
widgetButtonWrapper.appendChild(this.toggleButton);
|
||||
widgetButtonWrapper.appendChild(this.unreadBadge);
|
||||
this.toggleButton.style.position = 'relative';
|
||||
this.widgetButtonWrapper = widgetButtonWrapper;
|
||||
|
||||
// Create iframe
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.src = `${this.config.baseUrl}/widget/?inbox_id=${this.config.inboxID}`;
|
||||
@@ -109,9 +152,19 @@
|
||||
display: none;
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.toggleButton);
|
||||
document.body.appendChild(this.widgetButtonWrapper);
|
||||
document.body.appendChild(this.iframe);
|
||||
this.setLauncherPosition();
|
||||
}
|
||||
|
||||
sendMobileState () {
|
||||
this.isMobile = window.innerWidth <= 600;
|
||||
// Send message to iframe to update mobile state there.
|
||||
if (this.iframe && this.iframe.contentWindow) {
|
||||
this.iframe.contentWindow.postMessage({
|
||||
type: 'SET_MOBILE_STATE',
|
||||
isMobile: this.isMobile
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
setLauncherPosition () {
|
||||
@@ -120,9 +173,9 @@
|
||||
const position = launcher.position;
|
||||
const side = position === 'right' ? 'right' : 'left';
|
||||
|
||||
// Position toggle button
|
||||
this.toggleButton.style.bottom = `${spacing.bottom}px`;
|
||||
this.toggleButton.style[side] = `${spacing.side}px`;
|
||||
// Position button wrapper (which contains the toggle button and badge)
|
||||
this.widgetButtonWrapper.style.bottom = `${spacing.bottom}px`;
|
||||
this.widgetButtonWrapper.style[side] = `${spacing.side}px`;
|
||||
|
||||
// Position iframe
|
||||
this.iframe.style.bottom = `${spacing.bottom + 80}px`;
|
||||
@@ -131,6 +184,33 @@
|
||||
|
||||
setupEventListeners () {
|
||||
this.toggleButton.addEventListener('click', () => this.toggle());
|
||||
|
||||
// Listen for messages from the iframe (Vue widget app)
|
||||
window.addEventListener('message', (event) => {
|
||||
// Verify the message is from our iframe.
|
||||
if (event.source === this.iframe.contentWindow) {
|
||||
if (event.data.type === 'CLOSE_WIDGET') {
|
||||
this.hideChat();
|
||||
} else if (event.data.type === 'UPDATE_UNREAD_COUNT') {
|
||||
this.updateUnreadCount(event.data.count);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupMobileDetection () {
|
||||
window.addEventListener('resize', () => {
|
||||
this.sendMobileState();
|
||||
if (this.isChatVisible) {
|
||||
this.showChat();
|
||||
}
|
||||
});
|
||||
window.addEventListener('orientationchange', () => {
|
||||
this.sendMobileState();
|
||||
if (this.isChatVisible) {
|
||||
this.showChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle () {
|
||||
@@ -143,9 +223,36 @@
|
||||
|
||||
showChat () {
|
||||
if (this.iframe) {
|
||||
this.iframe.style.display = 'block';
|
||||
this.isMobile = window.innerWidth <= 600;
|
||||
if (this.isMobile) {
|
||||
this.iframe.style.display = 'block';
|
||||
this.iframe.style.position = 'fixed';
|
||||
this.iframe.style.top = '0';
|
||||
this.iframe.style.left = '0';
|
||||
this.iframe.style.width = '100vw';
|
||||
this.iframe.style.height = '100vh';
|
||||
this.iframe.style.borderRadius = '0';
|
||||
this.iframe.style.boxShadow = 'none';
|
||||
this.iframe.style.bottom = '';
|
||||
this.iframe.style.right = '';
|
||||
this.iframe.style.left = '';
|
||||
this.iframe.style.top = '0';
|
||||
this.widgetButtonWrapper.style.display = 'none';
|
||||
} else {
|
||||
this.iframe.style.display = 'block';
|
||||
this.iframe.style.position = 'fixed';
|
||||
this.iframe.style.width = '400px';
|
||||
this.iframe.style.height = '700px';
|
||||
this.iframe.style.borderRadius = '10px';
|
||||
this.iframe.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)';
|
||||
this.iframe.style.top = '';
|
||||
this.iframe.style.left = '';
|
||||
this.setLauncherPosition();
|
||||
this.widgetButtonWrapper.style.display = '';
|
||||
}
|
||||
this.isChatVisible = true;
|
||||
this.toggleButton.style.transform = 'scale(0.9)';
|
||||
this.unreadBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,13 +261,27 @@
|
||||
this.iframe.style.display = 'none';
|
||||
this.isChatVisible = false;
|
||||
this.toggleButton.style.transform = 'scale(1)';
|
||||
this.widgetButtonWrapper.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateUnreadCount (count) {
|
||||
this.unreadCount = count;
|
||||
|
||||
if (count > 0 && !this.isChatVisible) {
|
||||
this.unreadBadge.textContent = count > 99 ? '99+' : count.toString();
|
||||
this.unreadBadge.style.display = 'flex';
|
||||
} else {
|
||||
this.unreadBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
destroy () {
|
||||
if (this.toggleButton) {
|
||||
document.body.removeChild(this.toggleButton);
|
||||
if (this.widgetButtonWrapper) {
|
||||
document.body.removeChild(this.widgetButtonWrapper);
|
||||
this.widgetButtonWrapper = null;
|
||||
this.toggleButton = null;
|
||||
this.unreadBadge = null;
|
||||
}
|
||||
if (this.iframe) {
|
||||
document.body.removeChild(this.iframe);
|
||||
@@ -171,19 +292,19 @@
|
||||
}
|
||||
|
||||
// Global widget instance
|
||||
window.LibreDeskWidget = LibreDeskWidget;
|
||||
window.LibredeskWidget = LibredeskWidget;
|
||||
|
||||
// Auto-initialize if configuration is provided
|
||||
if (window.libreDeskConfig) {
|
||||
window.libreDeskWidget = new LibreDeskWidget(window.libreDeskConfig);
|
||||
if (window.LibredeskConfig) {
|
||||
window.libreDeskWidget = new LibredeskWidget(window.LibredeskConfig);
|
||||
}
|
||||
|
||||
window.initLibreDeskWidget = function (config = {}) {
|
||||
if (window.libreDeskWidget) {
|
||||
console.warn('LibreDesk Widget is already initialized');
|
||||
console.warn('Libredesk Widget is already initialized');
|
||||
return window.libreDeskWidget;
|
||||
}
|
||||
window.libreDeskWidget = new LibreDeskWidget(config);
|
||||
window.libreDeskWidget = new LibredeskWidget(config);
|
||||
return window.libreDeskWidget;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user