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:
Abhinav Raut
2025-07-17 01:06:54 +05:30
parent 282dc83439
commit 8ee81c2d64
82 changed files with 4281 additions and 1729 deletions
+332 -196
View File
@@ -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
-5
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+14
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+17
View File
@@ -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 {
+106
View File
@@ -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
View File
@@ -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
}
+33 -14
View File
@@ -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
*/
+98 -3
View File
@@ -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()
+59 -16
View File
@@ -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>
+20 -7
View File
@@ -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>
+51 -4
View File
@@ -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()
+116 -60
View File
@@ -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
}
})
+17 -1
View File
@@ -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
View File
@@ -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,
}
})
+13 -380
View File
@@ -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>
+42 -65
View File
@@ -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>
+42 -49
View File
@@ -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'
-14
View File
@@ -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'
+1
View File
@@ -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
}
}
+3 -3
View File
@@ -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
View File
@@ -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",
+7
View File
@@ -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 == "" {
+4 -1
View File
@@ -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;
+34
View File
@@ -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)
}
}
}
+127 -92
View File
@@ -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
}
+120 -55
View File
@@ -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 {
+26 -11
View File
@@ -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
+54
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
}
+90
View File
@@ -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
}
+1
View File
@@ -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,
+62 -18
View File
@@ -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
View File
@@ -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
}
+15 -13
View File
@@ -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:"-"`
}
+46 -3
View File
@@ -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 email 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
}
+12 -1
View File
@@ -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"`
+8 -7
View File
@@ -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
+5 -1
View File
@@ -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) {
+1 -7
View File
@@ -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
View File
@@ -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.
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};