Compare commits

..

10 Commits

Author SHA1 Message Date
Abhinav Raut
78b8c508d8 fix: message bubble styling for better text wrapping 2025-02-27 03:01:05 +05:30
Abhinav Raut
f17d96f96f rafactor: move full screen editor and non-fullscreen editor to a common component.
feat: add typography plugin and improve DOM purifying in conversation messages
fix: sooner not working in outer app.
fix: macro actions getting deleted when macro is remove from the text editor preview.
fix: square user avatar image in sidebar,made it rounded-lg
refactor: visual fixes and improvements to macro previews for consistency with attachment preview.
2025-02-27 02:47:23 +05:30
Abhinav Raut
c75c117a4d fix: improve password handling and error reporting during password reset 2025-02-27 01:58:08 +05:30
Abhinav Raut
873d26ccb2 fix: ensure deep copy of macros, as removing macro from editor was deleting the macro action from the macro store.
- fix: conversation macro cmds visible when conversation is not open.
2025-02-26 23:19:37 +05:30
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
47 changed files with 1108 additions and 636 deletions

View File

@@ -99,6 +99,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/users/me", auth(handleGetCurrentUser)) g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser)) g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams)) g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar)) g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact)) g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage")) g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))

View File

@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m return m
} }
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// initTemplates inits template manager. // initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
var ( var (

View File

@@ -24,7 +24,7 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
} }
// Make sure the system user password is strong enough. // Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")) password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled { if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint) log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
} }

View File

@@ -3,6 +3,7 @@ package main
import ( import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
@@ -11,14 +12,20 @@ import (
func handleLogin(r *fastglue.Request) error { func handleLogin(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
p = r.RequestCtx.PostArgs() email = string(r.RequestCtx.PostArgs().Peek("email"))
email = string(p.Peek("email")) password = r.RequestCtx.PostArgs().Peek("password")
password = p.Peek("password")
) )
user, err := app.user.VerifyPassword(email, password) user, err := app.user.VerifyPassword(email, password)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{ if err := app.auth.SaveSession(amodels.User{
ID: user.ID, ID: user.ID,
Email: user.Email.String, Email: user.Email.String,

View File

@@ -36,7 +36,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team" "github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
@@ -162,7 +161,6 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval") messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval") slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName) lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis() rdb = initRedis()
constants = initConstants() constants = initConstants()
i18n = initI18n(fs) i18n = initI18n(fs)
@@ -177,6 +175,7 @@ func main() {
team = initTeam(db) team = initTeam(db)
businessHours = initBusinessHours(db) businessHours = initBusinessHours(db)
user = initUser(i18n, db) user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user) notifier = initNotifier(user)
automation = initAutomationEngine(db) automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours) sla = initSLA(db, team, settings, businessHours)
@@ -193,6 +192,7 @@ func main() {
go notifier.Run(ctx) go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval) go sla.Run(ctx, slaEvaluationInterval)
go media.DeleteUnlinkedMedia(ctx) go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{ var app = &App{
lo: lo, lo: lo,

View File

@@ -43,9 +43,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// auth makes sure the user is logged in. // auth makes sure the user is logged in.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error { return func(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
// Validate session and fetch user. // Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r) userSession, err := app.auth.ValidateSession(r)

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/migrations"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin" "github.com/knadh/stuffbin"
@@ -28,7 +29,9 @@ type migFunc struct {
// migList is the list of available migList ordered by the semver. // migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver. // Each migration is a Go file in internal/migrations named after the semver.
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent. // The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{} var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
}
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one. // for all version from the last known version to the current one.

View File

@@ -22,7 +22,7 @@ import (
) )
const ( const (
maxAvatarSizeMB = 5 maxAvatarSizeMB = 20
) )
// handleGetUsers returns all users. // handleGetUsers returns all users.
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
// handleGetUsersCompact returns all users in a compact format. // handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error { func handleGetUsersCompact(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
agents, err := app.user.GetAllCompact() agents, err := app.user.GetAllCompact()
if err != nil { if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "") return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
@@ -66,6 +64,19 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendEnvelope(user) return r.SendEnvelope(user)
} }
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
}
// handleGetCurrentUserTeams returns the teams of a user. // handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error { func handleGetCurrentUserTeams(r *fastglue.Request) error {
var ( var (
@@ -228,7 +239,7 @@ func handleCreateUser(r *fastglue.Request) error {
Provider: notifier.ProviderEmail, Provider: notifier.ProviderEmail,
}); err != nil { }); err != nil {
app.lo.Error("error sending notification message", "error", err) app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope("User created successfully, but error sending welcome email.") return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
} }
} }
return r.SendEnvelope("User created successfully.") return r.SendEnvelope("User created successfully.")

View File

@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
### Running the Dev Environment ### Running the Dev Environment
1. Run `make run` to start the libredesk backend dev server on `:9000`. 1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config. 2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
--- ---

View File

@@ -15,9 +15,9 @@ Libredesk is a single binary application that requires postgres and redis to run
## Docker ## Docker
The latest image is available on DockerHub at `libredesk/llibredeskistmonk:latest` The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`. The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell ```shell
# Download the compose file and the sample config file in the current directory. # Download the compose file and the sample config file in the current directory.
@@ -41,7 +41,7 @@ Go to `http://localhost:9000` and login with the email `System` and the password
## Compiling from source ## Compiling from source
To compile the latest unreleased version (`master` branch): To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system. 1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git` 2. `git clone git@github.com:abhinavxd/libredesk.git`

View File

@@ -1,6 +1,6 @@
{ {
"name": "libredesk", "name": "libredesk",
"version": "0.0.0", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,6 +18,7 @@
"@formkit/auto-animate": "^0.8.2", "@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5", "@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0", "@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/vue-table": "^8.19.2", "@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9", "@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1", "@tiptap/extension-link": "^2.9.1",
@@ -43,6 +44,7 @@
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"vee-validate": "^4.13.2", "vee-validate": "^4.13.2",
"vue": "^3.4.37", "vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9", "vue-i18n": "9",
"vue-letter": "^0.2.0", "vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",

View File

@@ -17,6 +17,9 @@ importers:
'@radix-icons/vue': '@radix-icons/vue':
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0(vue@3.5.13(typescript@5.7.3)) version: 1.0.0(vue@3.5.13(typescript@5.7.3))
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.16(tailwindcss@3.4.17)
'@tanstack/vue-table': '@tanstack/vue-table':
specifier: ^8.19.2 specifier: ^8.19.2
version: 8.20.5(vue@3.5.13(typescript@5.7.3)) version: 8.20.5(vue@3.5.13(typescript@5.7.3))
@@ -92,6 +95,9 @@ importers:
vue: vue:
specifier: ^3.4.37 specifier: ^3.4.37
version: 3.5.13(typescript@5.7.3) version: 3.5.13(typescript@5.7.3)
vue-dompurify-html:
specifier: ^5.2.0
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n: vue-i18n:
specifier: '9' specifier: '9'
version: 9.14.2(vue@3.5.13(typescript@5.7.3)) version: 9.14.2(vue@3.5.13(typescript@5.7.3))
@@ -737,6 +743,11 @@ packages:
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/typography@0.5.16':
resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/table-core@8.20.5': '@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1076,6 +1087,9 @@ packages:
'@types/topojson@3.2.6': '@types/topojson@3.2.6':
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==} resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.20': '@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -1718,6 +1732,9 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2317,12 +2334,18 @@ packages:
lodash-es@4.17.21: lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.clonedeep@4.5.0: lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.isequal@4.5.0: lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2615,6 +2638,10 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.2.14 postcss: ^8.2.14
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3191,6 +3218,11 @@ packages:
'@vue/composition-api': '@vue/composition-api':
optional: true optional: true
vue-dompurify-html@5.2.0:
resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
peerDependencies:
vue: ^3.0.0
vue-eslint-parser@9.4.3: vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0} engines: {node: ^14.17.0 || >=16.0.0}
@@ -3802,6 +3834,14 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17
'@tanstack/table-core@8.20.5': {} '@tanstack/table-core@8.20.5': {}
'@tanstack/virtual-core@3.11.2': {} '@tanstack/virtual-core@3.11.2': {}
@@ -4187,6 +4227,9 @@ snapshots:
'@types/topojson-simplify': 3.0.3 '@types/topojson-simplify': 3.0.3
'@types/topojson-specification': 1.0.5 '@types/topojson-specification': 1.0.5
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.20': {} '@types/web-bluetooth@0.0.20': {}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
@@ -4963,6 +5006,10 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
dompurify@3.2.4:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.1 call-bind-apply-helpers: 1.0.1
@@ -5598,10 +5645,14 @@ snapshots:
lodash-es@4.17.21: {} lodash-es@4.17.21: {}
lodash.castarray@4.4.0: {}
lodash.clonedeep@4.5.0: {} lodash.clonedeep@4.5.0: {}
lodash.isequal@4.5.0: {} lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.once@4.1.1: {} lodash.once@4.1.1: {}
@@ -5873,6 +5924,11 @@ snapshots:
postcss: 8.4.49 postcss: 8.4.49
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -6530,6 +6586,11 @@ snapshots:
dependencies: dependencies:
vue: 3.5.13(typescript@5.7.3) vue: 3.5.13(typescript@5.7.3)
vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
dependencies:
dompurify: 3.2.4
vue: 3.5.13(typescript@5.7.3)
vue-eslint-parser@9.4.3(eslint@8.57.1): vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies: dependencies:
debug: 4.4.0(supports-color@8.1.1) debug: 4.4.0(supports-color@8.1.1)

View File

@@ -48,8 +48,13 @@
@delete-view="deleteView" @delete-view="deleteView"
> >
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<AppUpdate /> <!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader /> <PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" /> <RouterView class="flex-grow" />
</div> </div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" /> <ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
@@ -76,6 +81,7 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla' import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro' import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag' import { useTagStore } from '@/stores/tag'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue' import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue' import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue' import AppUpdate from '@/components/update/AppUpdate.vue'
@@ -113,6 +119,8 @@ const view = ref({})
const openCreateViewForm = ref(false) const openCreateViewForm = ref(false)
initWS() initWS()
useIdleDetection()
onMounted(() => { onMounted(() => {
initToaster() initToaster()
listenViewRefresh() listenViewRefresh()
@@ -121,8 +129,10 @@ onMounted(() => {
// initialize data stores // initialize data stores
const initStores = async () => { const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([ await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(), getUserViews(),
conversationStore.fetchStatuses(), conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(), conversationStore.fetchPriorities(),

View File

@@ -1,7 +1,27 @@
<template> <template>
<RouterView /> <RouterView />
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
</script> import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()
onMounted(() => {
initToaster()
})
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
</script>

View File

@@ -169,6 +169,7 @@ const updateCurrentUser = (data) =>
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar') const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me') const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams') const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
const getTags = () => http.get('/api/v1/tags') const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data) const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data) const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
@@ -323,6 +324,7 @@ export default {
uploadMedia, uploadMedia,
updateAssigneeLastSeen, updateAssigneeLastSeen,
updateUser, updateUser,
updateCurrentUserAvailability,
updateAutomationRule, updateAutomationRule,
updateAutomationRuleWeights, updateAutomationRuleWeights,
updateAutomationRulesExecutionMode, updateAutomationRulesExecutionMode,

View File

@@ -1,82 +1,93 @@
<template> <template>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg" <SidebarMenuButton
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"> size="lg"
<Avatar class="h-8 w-8 rounded-lg"> class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
<AvatarImage :src="userStore.avatar" alt="Abhinav" /> >
<AvatarFallback class="rounded-lg"> <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
{{ userStore.getInitials }} <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg"/>
</AvatarFallback> <AvatarFallback class="rounded-lg">
</Avatar> {{ userStore.getInitials }}
<div class="grid flex-1 text-left text-sm leading-tight"> </AvatarFallback>
<span class="truncate font-semibold">{{ userStore.getFullName }}</span> <div
<span class="truncate text-xs">{{ userStore.email }}</span> class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
</div> :class="{
<ChevronsUpDown class="ml-auto size-4" /> 'bg-green-500': userStore.user.availability_status === 'online',
</SidebarMenuButton> 'bg-amber-500': userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual',
</DropdownMenuTrigger> 'bg-gray-400': userStore.user.availability_status === 'offline'
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom" }"
:side-offset="4"> ></div>
<DropdownMenuLabel class="p-0 font-normal"> </Avatar>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div class="grid flex-1 text-left text-sm leading-tight">
<Avatar class="h-8 w-8 rounded-lg"> <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<AvatarImage :src="userStore.avatar" alt="Abhinav" /> <span class="truncate text-xs">{{ userStore.email }}</span>
<AvatarFallback class="rounded-lg"> </div>
{{ userStore.getInitials }} <ChevronsUpDown class="ml-auto size-4" />
</AvatarFallback> </SidebarMenuButton>
</Avatar> </DropdownMenuTrigger>
<div class="grid flex-1 text-left text-sm leading-tight"> <DropdownMenuContent
<span class="truncate font-semibold">{{ userStore.getFullName }}</span> class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
<span class="truncate text-xs">{{ userStore.email }}</span> side="bottom"
</div> :side-offset="4"
</div> >
</DropdownMenuLabel> <DropdownMenuLabel class="p-0 font-normal space-y-1">
<DropdownMenuSeparator /> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<DropdownMenuGroup> <Avatar class="h-8 w-8 rounded-lg">
<DropdownMenuItem> <AvatarImage :src="userStore.avatar" alt="Abhinav" />
<router-link to="/account" class="flex items-center"> <AvatarFallback class="rounded-lg">
<CircleUserRound size="18" class="mr-2" /> {{ userStore.getInitials }}
Account </AvatarFallback>
</router-link> </Avatar>
</DropdownMenuItem> <div class="grid flex-1 text-left text-sm leading-tight">
</DropdownMenuGroup> <span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<DropdownMenuSeparator /> <span class="truncate text-xs">{{ userStore.email }}</span>
<DropdownMenuItem @click="logout"> </div>
<LogOut size="18" class="mr-2" /> </div>
Log out <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
</DropdownMenuItem> <span class="text-muted-foreground">Away</span>
</DropdownMenuContent> <Switch
</DropdownMenu> :checked="userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual'"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<router-link to="/account" class="flex items-center">
<CircleUserRound size="18" class="mr-2" />
Account
</router-link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template> </template>
<script setup> <script setup>
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import { SidebarMenuButton } from '@/components/ui/sidebar'
SidebarMenuButton, import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
} from '@/components/ui/sidebar' import { Switch } from '@/components/ui/switch'
import { import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
ChevronsUpDown,
CircleUserRound,
LogOut,
} from 'lucide-vue-next'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()
const logout = () => { const logout = () => {
window.location.href = '/logout' window.location.href = '/logout'
} }
</script> </script>

View File

@@ -0,0 +1,43 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
export function useIdleDetection () {
const userStore = useUserStore()
// 4 minutes
const AWAY_THRESHOLD = 4 * 60 * 1000
// 1 minute
const CHECK_INTERVAL = 60 * 1000
const lastActivity = ref(Date.now())
const timer = ref(null)
function resetTimer () {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
lastActivity.value = Date.now()
}
const debouncedResetTimer = debounce(resetTimer, 200)
function checkIdle () {
if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
userStore.user.availability_status === 'online') {
userStore.updateUserAvailability('away', false)
}
}
onMounted(() => {
window.addEventListener('mousemove', debouncedResetTimer)
window.addEventListener('keypress', debouncedResetTimer)
window.addEventListener('click', debouncedResetTimer)
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', debouncedResetTimer)
window.removeEventListener('keypress', debouncedResetTimer)
window.removeEventListener('click', debouncedResetTimer)
clearInterval(timer.value)
})
}

View File

@@ -10,7 +10,7 @@
<CommandGroup <CommandGroup
heading="Conversations" heading="Conversations"
value="conversations" value="conversations"
v-if="nestedCommand === null && conversationStore.current" v-if="nestedCommand === null && conversationStore.hasConversationOpen"
> >
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem> <CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem> <CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
@@ -45,7 +45,6 @@
:data-index="index" :data-index="index"
@select="handleApplyMacro(macro)" @select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary" class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
> >
<div class="flex items-center space-x-2 justify-start"> <div class="flex items-center space-x-2 justify-start">
<Zap :size="14" class="text-primary" /> <Zap :size="14" class="text-primary" />
@@ -59,7 +58,7 @@
<div v-if="replyContent" class="space-y-1"> <div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p> <p class="text-xs font-semibold text-primary">Reply Preview</p>
<div <div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm" class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm prose-sm"
v-html="replyContent" v-html="replyContent"
/> />
</div> </div>
@@ -219,7 +218,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
const highlightedMacro = ref(null) const highlightedMacro = ref(null)
function handleApplyMacro(macro) { function handleApplyMacro(macro) {
conversationStore.setMacro(macro) // Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
conversationStore.setMacro(plainMacro)
handleOpenChange() handleOpenChange()
} }

View File

@@ -38,7 +38,7 @@
<div class="flex flex-col flex-grow overflow-hidden"> <div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" /> <MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0"> <div class="sticky bottom-0">
<ReplyBox class="h-max" /> <ReplyBox class="h-full" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="max-h-[600px] overflow-y-auto"> <div class="editor-wrapper h-full overflow-y-auto">
<BubbleMenu <BubbleMenu
:editor="editor" :editor="editor"
:tippy-options="{ duration: 100 }" :tippy-options="{ duration: 100 }"
@@ -179,13 +179,20 @@ watchEffect(() => {
watch( watch(
() => props.contentToSet, () => props.contentToSet,
(newContent) => { (newContentData) => {
if (newContent === '') { if (!newContentData) return
editor.value?.commands.clearContent() try {
} else { const parsedData = JSON.parse(newContentData)
editor.value?.commands.setContent(newContent, true) const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
} }
editor.value?.commands.focus()
} }
) )
@@ -243,22 +250,26 @@ onUnmounted(() => {
height: 0; height: 0;
} }
// Editor height // Ensure the parent div has a proper height
.ProseMirror { .editor-wrapper div[aria-expanded='false'] {
min-height: 80px !important; display: flex;
max-height: 60% !important; flex-direction: column;
overflow-y: scroll !important; height: 100%;
} }
.fullscreen-tiptap-editor { // Ensure the editor content has a proper height and breaks words
@apply p-0; .tiptap.ProseMirror {
.ProseMirror { flex: 1;
min-height: 600px !important; min-height: 70px;
width: 90%; overflow-y: auto;
scrollbar-width: none; word-wrap: break-word !important;
} overflow-wrap: break-word !important;
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
} }
// Anchor tag styling
.tiptap { .tiptap {
a { a {
color: #0066cc; color: #0066cc;

View File

@@ -1,16 +1,16 @@
<template> <template>
<div class="flex flex-wrap px-2 py-1"> <div class="flex flex-wrap">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap">
<div <div
v-for="action in actions" v-for="action in actions"
:key="action.type" :key="action.type"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group" class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
> >
<div class="flex items-center space-x-2 px-3 py-2"> <div class="flex items-center space-x-2 px-2 ">
<component <component
:is="getIcon(action.type)" :is="getIcon(action.type)"
size="16" size="16"
class="text-primary group-hover:text-primary" class="text-gray-500 text-primary group-hover:text-primary"
/> />
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@@ -27,7 +27,7 @@
</div> </div>
<button <button
@click.stop="onRemove(action)" @click.stop="onRemove(action)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out" class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove action" title="Remove action"
> >
<X size="14" /> <X size="14" />

View File

@@ -3,328 +3,137 @@
<!-- Fullscreen editor --> <!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false"> <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent <DialogContent
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4" class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
@escapeKeyDown="isEditorFullscreen = false" @escapeKeyDown="isEditorFullscreen = false"
hide-close-button="true" :hide-close-button="true"
> >
<div v-if="isEditorFullscreen" class="h-full flex flex-col"> <ReplyBoxContent
<!-- Message type toggle --> v-if="isEditorFullscreen"
<div class="flex justify-between items-center border-b border-border pb-4"> :isFullscreen="true"
<Tabs v-model="messageType" class="rounded-lg"> :aiPrompts="aiPrompts"
<TabsList class="bg-muted p-1 rounded-lg"> :isSending="isSending"
<TabsTrigger :uploadingFiles="uploadingFiles"
value="reply" :clearEditorContent="clearEditorContent"
class="px-3 py-1 rounded-lg transition-colors duration-200" :htmlContent="htmlContent"
:class="{ 'bg-background text-foreground': messageType === 'reply' }" :textContent="textContent"
> :selectedText="selectedText"
Reply :isBold="isBold"
</TabsTrigger> :isItalic="isItalic"
<TabsTrigger :cursorPosition="cursorPosition"
value="private_note" :contentToSet="contentToSet"
class="px-3 py-1 rounded-lg transition-colors duration-200" :cc="cc"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }" :bcc="bcc"
> :emailErrors="emailErrors"
Private note :messageType="messageType"
</TabsTrigger> :showBcc="showBcc"
</TabsList> @update:htmlContent="htmlContent = $event"
</Tabs> @update:textContent="textContent = $event"
<span @update:selectedText="selectedText = $event"
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer" @update:isBold="isBold = $event"
variant="ghost" @update:isItalic="isItalic = $event"
@click="isEditorFullscreen = false" @update:cursorPosition="cursorPosition = $event"
> @toggleFullscreen="isEditorFullscreen = false"
<Minimize2 size="18" /> @update:messageType="messageType = $event"
</span> @update:cc="cc = $event"
</div> @update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
<!-- CC and BCC fields --> @updateEmailErrors="emailErrors = $event"
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'"> @send="processSend"
<div class="flex items-center space-x-2"> @fileUpload="handleFileUpload"
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label> @inlineImageUpload="handleInlineImageUpload"
<Input @fileDelete="handleOnFileDelete"
type="text" @aiPromptSelected="handleAiPromptSelected"
placeholder="Email addresses separated by comma" class="h-full flex-grow"
v-model="cc" />
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<div class="flex-grow overflow-y-auto p-2">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
class="h-full"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-4"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-4 pt-4"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<!-- Main Editor non-fullscreen --> <!-- Main Editor non-fullscreen -->
<div class="bg-card text-card-foreground box px-2 pt-2 m-2"> <div
<div v-if="!isEditorFullscreen" class=""> class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
<!-- Message type toggle --> v-if="!isEditorFullscreen"
<div class="flex justify-between items-center mb-4"> >
<Tabs v-model="messageType" class="rounded-lg"> <ReplyBoxContent
<TabsList class="bg-muted p-1 rounded-lg"> :isFullscreen="false"
<TabsTrigger :aiPrompts="aiPrompts"
value="reply" :isSending="isSending"
class="px-3 py-1 rounded-lg transition-colors duration-200" :uploadingFiles="uploadingFiles"
:class="{ 'bg-background text-foreground': messageType === 'reply' }" :clearEditorContent="clearEditorContent"
> :htmlContent="htmlContent"
Reply :textContent="textContent"
</TabsTrigger> :selectedText="selectedText"
<TabsTrigger :isBold="isBold"
value="private_note" :isItalic="isItalic"
class="px-3 py-1 rounded-lg transition-colors duration-200" :cursorPosition="cursorPosition"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }" :contentToSet="contentToSet"
> :cc="cc"
Private note :bcc="bcc"
</TabsTrigger> :emailErrors="emailErrors"
</TabsList> :messageType="messageType"
</Tabs> :showBcc="showBcc"
<span @update:htmlContent="htmlContent = $event"
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2" @update:textContent="textContent = $event"
variant="ghost" @update:selectedText="selectedText = $event"
@click="isEditorFullscreen = true" @update:isBold="isBold = $event"
> @update:isItalic="isItalic = $event"
<Maximize2 size="15" /> @update:cursorPosition="cursorPosition = $event"
</span> @toggleFullscreen="isEditorFullscreen = true"
</div> @update:messageType="messageType = $event"
@update:cc="cc = $event"
<div class="space-y-3 mb-4" v-if="messageType === 'reply'"> @update:bcc="bcc = $event"
<div class="flex items-center space-x-2"> @update:showBcc="showBcc = $event"
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label> @updateEmailErrors="emailErrors = $event"
<Input @send="processSend"
type="text" @fileUpload="handleFileUpload"
placeholder="Email addresses separated by comma" @inlineImageUpload="handleInlineImageUpload"
v-model="cc" @fileDelete="handleOnFileDelete"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring" @aiPromptSelected="handleAiPromptSelected"
@blur="validateEmails('cc')" />
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, nextTick, watch } from 'vue' import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings' import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import api from '@/api' import api from '@/api'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog' import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue' import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const emitter = useEmitter() const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null) // Shared state between the two editor components.
const clearEditorContent = ref(false) const clearEditorContent = ref(false)
const isEditorFullscreen = ref(false) const isEditorFullscreen = ref(false)
const isSending = ref(false) const isSending = ref(false)
const cursorPosition = ref(0)
const selectedText = ref('')
const htmlContent = ref('')
const textContent = ref('')
const contentToSet = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const messageType = ref('reply') const messageType = ref('reply')
const showBcc = ref(false)
const cc = ref('') const cc = ref('')
const bcc = ref('') const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([]) const emailErrors = ref([])
const aiPrompts = ref([]) const aiPrompts = ref([])
const uploadingFiles = ref([]) const uploadingFiles = ref([])
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.' const htmlContent = ref('')
const textContent = ref('')
const selectedText = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const cursorPosition = ref(0)
const contentToSet = ref('')
onMounted(async () => { onMounted(async () => {
await fetchAiPrompts() await fetchAiPrompts()
}) })
const hideBcc = () => { /**
showBcc.value = !showBcc.value * Fetches AI prompts from the server.
} */
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
if (newBcc.length == 0) {
showBcc.value = false
} else {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
const fetchAiPrompts = async () => { const fetchAiPrompts = async () => {
try { try {
const resp = await api.getAiPrompts() const resp = await api.getAiPrompts()
@@ -338,13 +147,22 @@ const fetchAiPrompts = async () => {
} }
} }
/**
* Handles the AI prompt selection event.
* Sends the selected prompt key and the current text content to the server for completion.
* Sets the response as the new content in the editor.
* @param {String} key - The key of the selected AI prompt
*/
const handleAiPromptSelected = async (key) => { const handleAiPromptSelected = async (key) => {
try { try {
const resp = await api.aiCompletion({ const resp = await api.aiCompletion({
prompt_key: key, prompt_key: key,
content: selectedText.value content: textContent.value
})
contentToSet.value = JSON.stringify({
content: resp.data.data.replace(/\n/g, '<br>'),
timestamp: Date.now()
}) })
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error', title: 'Error',
@@ -354,33 +172,11 @@ const handleAiPromptSelected = async (key) => {
} }
} }
const toggleBold = () => { /**
isBold.value = !isBold.value * Handles the file upload process when files are selected.
} * Uploads each file to the server and adds them to the conversation's mediaFiles.
* @param {Event} event - The file input change event containing selected files
const toggleItalic = () => { */
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!uploadingFiles.value.length
)
})
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
const handleFileUpload = (event) => { const handleFileUpload = (event) => {
const files = Array.from(event.target.files) const files = Array.from(event.target.files)
uploadingFiles.value = files uploadingFiles.value = files
@@ -407,6 +203,7 @@ const handleFileUpload = (event) => {
} }
} }
// Inline image upload is not supported yet.
const handleInlineImageUpload = (event) => { const handleInlineImageUpload = (event) => {
for (const file of event.target.files) { for (const file of event.target.files) {
api api
@@ -416,12 +213,13 @@ const handleInlineImageUpload = (event) => {
linked_model: 'messages' linked_model: 'messages'
}) })
.then((resp) => { .then((resp) => {
setInlineImage.value = { const imageData = {
src: resp.data.data.url, src: resp.data.data.url,
alt: resp.data.data.filename, alt: resp.data.data.filename,
title: resp.data.data.uuid title: resp.data.data.uuid
} }
conversationStore.conversation.mediaFiles.push(resp.data.data) conversationStore.conversation.mediaFiles.push(resp.data.data)
return imageData
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -433,44 +231,23 @@ const handleInlineImageUpload = (event) => {
} }
} }
const validateEmails = (field) => { /**
const emails = field === 'cc' ? cc.value : bcc.value * Returns true if the editor has text content.
const emailList = emails */
.split(',') const hasTextContent = computed(() => {
.map((e) => e.trim()) return textContent.value.trim().length > 0
.filter((e) => e !== '') })
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
const handleSend = async () => {
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
/**
* Processes the send action.
*/
const processSend = async () => {
isEditorFullscreen.value = false isEditorFullscreen.value = false
try { try {
isSending.value = true isSending.value = true
// Send message if there is text content in the editor. // Send message if there is text content in the editor.
if (hasTextContent.value) { if (hasTextContent.value > 0) {
// Replace inline image url with cid. // Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value) const message = transformImageSrcToCID(htmlContent.value)
@@ -490,7 +267,7 @@ const handleSend = async () => {
) )
await api.sendMessage(conversationStore.current.uuid, { await api.sendMessage(conversationStore.current.uuid, {
private: messageType.value === 'private_note', private: messageType.value === 'private',
message: message, message: message,
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id), attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
// Convert email addresses to array and remove empty strings. // Convert email addresses to array and remove empty strings.
@@ -498,7 +275,7 @@ const handleSend = async () => {
.split(',') .split(',')
.map((email) => email.trim()) .map((email) => email.trim())
.filter((email) => email), .filter((email) => email),
bcc: showBcc.value bcc: bcc.value
? bcc.value ? bcc.value
.split(',') .split(',')
.map((email) => email.trim()) .map((email) => email.trim())
@@ -524,6 +301,7 @@ const handleSend = async () => {
} finally { } finally {
isSending.value = false isSending.value = false
clearEditorContent.value = true clearEditorContent.value = true
// Reset media and macro in conversation store.
conversationStore.resetMacro() conversationStore.resetMacro()
conversationStore.resetMediaFiles() conversationStore.resetMediaFiles()
emailErrors.value = [] emailErrors.value = []
@@ -531,33 +309,64 @@ const handleSend = async () => {
clearEditorContent.value = false clearEditorContent.value = false
}) })
} }
// Update assignee last seen timestamp.
api.updateAssigneeLastSeen(conversationStore.current.uuid) api.updateAssigneeLastSeen(conversationStore.current.uuid)
} }
/**
* Handles the file delete event.
* Removes the file from the conversation's mediaFiles.
* @param {String} uuid - The UUID of the file to delete
*/
const handleOnFileDelete = (uuid) => { const handleOnFileDelete = (uuid) => {
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter( conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(item) => item.uuid !== uuid (item) => item.uuid !== uuid
) )
} }
const handleEmojiSelect = (emoji) => { /**
insertContent.value = undefined * Watches for changes in the conversation's macro and updates the editor content with the macro content.
// Force reactivity so the user can select the same emoji multiple times */
nextTick(() => (insertContent.value = emoji))
}
// Watch for changes in macro content and update editor content.
watch( watch(
() => conversationStore.conversation.macro, () => conversationStore.conversation.macro,
() => { () => {
// hack: Quill editor adds <p><br></p> replace with <p></p> // hack: Quill editor adds <p><br></p> replace with <p></p>
// Maybe use some other editor that doesn't add this?
if (conversationStore.conversation?.macro?.message_content) { if (conversationStore.conversation?.macro?.message_content) {
contentToSet.value = conversationStore.conversation.macro.message_content.replace( const contentToRender = conversationStore.conversation.macro.message_content.replace(
/<p><br><\/p>/g, /<p><br><\/p>/g,
'<p></p>' '<p></p>'
) )
// Add timestamp to ensure the watcher detects the change even for identical content,
// As user can send the same macro multiple times.
contentToSet.value = JSON.stringify({
content: contentToRender,
timestamp: Date.now()
})
} }
}, },
{ deep: true } { deep: true }
) )
// Initialize cc and bcc from conversation store
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
// Only show BCC field if it has content
if (newBcc.length > 0) {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
</script> </script>

View File

@@ -0,0 +1,305 @@
<template>
<div class="flex flex-col h-full max-h-[600px]">
<!-- Message type toggle -->
<div
class="flex justify-between items-center"
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="toggleFullscreen"
>
<component
:is="isFullscreen ? Minimize2 : Maximize2"
:size="isFullscreen ? '18' : '15'"
:class="{ 'mr-2': !isFullscreen }"
/>
</span>
</div>
<!-- CC and BCC fields -->
<div
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
v-if="messageType === 'reply'"
>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="toggleBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<!-- CC and BCC field validation errors -->
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:cursorPosition="cursorPosition"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-2"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-2"
/>
<!-- Editor menu bar with send button -->
<ReplyBoxMenuBar
class="mt-1 shrink-0"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' })
const cc = defineModel('cc', { default: '' })
const bcc = defineModel('bcc', { default: '' })
const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const props = defineProps({
isFullscreen: {
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
},
uploadingFiles: {
type: Array,
required: true
},
clearEditorContent: {
type: Boolean,
required: true
},
contentToSet: {
type: String,
default: null
}
})
const emit = defineEmits([
'toggleFullscreen',
'send',
'fileUpload',
'inlineImageUpload',
'fileDelete',
'aiPromptSelected'
])
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
const toggleBcc = async () => {
showBcc.value = !showBcc.value
await nextTick()
// If hiding BCC field, clear the content
if (!showBcc.value) {
bcc.value = ''
}
}
const toggleFullscreen = () => {
emit('toggleFullscreen')
}
const toggleBold = () => {
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
)
})
/**
* Validate email addresses in the CC and BCC fields
* @param {string} field - 'cc' or 'bcc'
*/
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
/**
* Send the reply or private note
*/
const handleSend = async () => {
validateEmails('cc')
validateEmails('bcc')
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
emit('send')
}
const handleFileUpload = (event) => {
emit('fileUpload', event)
}
const handleInlineImageUpload = (event) => {
emit('inlineImageUpload', event)
}
const handleOnFileDelete = (uuid) => {
emit('fileDelete', uuid)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key)
}
</script>

View File

@@ -35,7 +35,9 @@
<Smile class="h-4 w-4" /> <Smile class="h-4 w-4" />
</Toggle> </Toggle>
</div> </div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button> <Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
>Send</Button
>
</div> </div>
</template> </template>
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
const inlineImageInput = ref(null) const inlineImageInput = ref(null)
const isEmojiPickerVisible = ref(false) const isEmojiPickerVisible = ref(false)
const emojiPickerRef = ref(null) const emojiPickerRef = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect']) const emit = defineEmits(['emojiSelect'])
// Using defineProps for props that don't need two-way binding
defineProps({ defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean, isSending: Boolean,
enableSend: Boolean, enableSend: Boolean,
handleSend: Function, handleSend: Function,
@@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
}) })
const triggerFileUpload = () => { const triggerFileUpload = () => {
attachmentInput.value.click() if (attachmentInput.value) {
// Clear the value to allow the same file to be uploaded again.
attachmentInput.value.value = ''
attachmentInput.value.click()
}
} }
const toggleEmojiPicker = () => { const toggleEmojiPicker = () => {

View File

@@ -19,7 +19,11 @@
}" }"
> >
<!-- Message Content --> <!-- Message Content -->
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div> <div
v-dompurify-html="messageContent"
class="whitespace-pre-wrap break-words overflow-wrap-anywhere"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
<!-- Attachments --> <!-- Attachments -->
<MessageAttachmentPreview :attachments="nonInlineAttachments" /> <MessageAttachmentPreview :attachments="nonInlineAttachments" />
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
api.retryMessage(convStore.current.uuid, msg.uuid) api.retryMessage(convStore.current.uuid, msg.uuid)
} }
</script> </script>
<style scoped>
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>

View File

@@ -57,7 +57,7 @@
leave-from-class="opacity-100 translate-y-0" leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1" leave-to-class="opacity-0 translate-y-1"
> >
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10"> <div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
<button <button
@click="handleScrollToBottom" @click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100" class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"

View File

@@ -1,25 +1,36 @@
<template> <template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white"> <div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p> <p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded"> <div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span> <span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p> <p class="uppercase text-xs">Live</p>
</div> </div>
</div> </div>
<div class="flex justify-between pr-32"> <div class="flex justify-between pr-32">
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2"> <div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
>
<span class="text-muted-foreground">{{ labels[key] }}</span> <span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ value }}</span> <span class="text-2xl font-medium">{{ item }}</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue'
const props = defineProps({
counts: { type: Object, required: true }, counts: { type: Object, required: true },
labels: { type: Object, required: true }, labels: { type: Object, required: true },
title: { type: String, required: true } title: { type: String, required: true }
}) })
// Filter out counts that don't have a label
const filteredCounts = computed(() => {
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
})
</script> </script>

View File

@@ -7,6 +7,7 @@ import mitt from 'mitt'
import api from './api' import api from './api'
import './assets/styles/main.scss' import './assets/styles/main.scss'
import './utils/strings.js' import './utils/strings.js'
import VueDOMPurifyHTML from 'vue-dompurify-html'
import Root from './Root.vue' import Root from './Root.vue'
const setFavicon = (url) => { const setFavicon = (url) => {
@@ -50,6 +51,7 @@ async function initApp () {
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
app.use(VueDOMPurifyHTML)
app.mount('#app') app.mount('#app')
} }

View File

@@ -65,7 +65,6 @@ const routes = [
path: '', path: '',
name: 'team-inbox', name: 'team-inbox',
component: InboxView, component: InboxView,
props: true,
meta: { title: 'Team inbox' } meta: { title: 'Team inbox' }
}, },
{ {
@@ -88,7 +87,6 @@ const routes = [
path: '', path: '',
name: 'view-inbox', name: 'view-inbox',
component: InboxView, component: InboxView,
props: true,
meta: { title: 'View inbox' } meta: { title: 'View inbox' }
}, },
{ {
@@ -118,7 +116,6 @@ const routes = [
path: '', path: '',
name: 'inbox', name: 'inbox',
component: InboxView, component: InboxView,
props: true,
meta: { meta: {
title: 'Inbox', title: 'Inbox',
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref, nextTick } from 'vue'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation' import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { handleHTTPError } from '@/utils/http' import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
clearInterval(reRenderInterval) clearInterval(reRenderInterval)
} }
function setMacro (macros) { async function setMacro (macro) {
conversation.macro = macros // Clear existing macro.
conversation.macro = {}
await nextTick()
conversation.macro = macro
} }
function removeMacroAction (action) { function removeMacroAction (action) {
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
return conversation.data || {} return conversation.data || {}
}) })
const hasConversationOpen = computed(() => {
return Object.keys(conversation.data || {}).length > 0
})
const currentBCC = computed(() => { const currentBCC = computed(() => {
return conversation.data?.bcc || [] return conversation.data?.bcc || []
}) })
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchMessages (uuid, fetchNextPage = false) { async function fetchMessages (uuid, fetchNextPage = false) {
// Messages are already cached? // Messages are already cached?
let hasMessages = messages.data.getAllPagesMessages(uuid) let hasMessages = messages.data.getAllPagesMessages(uuid)
if (hasMessages.length > 0 && !fetchNextPage) if (hasMessages.length > 0 && !fetchNextPage) {
markConversationAsRead(uuid)
return return
}
// Fetch messages from server. // Fetch messages from server.
messages.loading = true messages.loading = true
@@ -293,7 +302,6 @@ export const useConversationStore = defineStore('conversation', () => {
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE }) const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
const result = response.data?.data || {} const result = response.data?.data || {}
const newMessages = result.results || [] const newMessages = result.results || []
// Mark conversation as read
markConversationAsRead(uuid) markConversationAsRead(uuid)
// Cache messages // Cache messages
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages) messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, { Object.assign(conversation, {
data: null, data: null,
participants: {}, participants: {},
macro: {},
mediaFiles: [], mediaFiles: [],
macro: {},
loading: false, loading: false,
errorMessage: '' errorMessage: ''
}) })
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
conversationsList, conversationsList,
conversationMessages, conversationMessages,
currentConversationHasMoreMessages, currentConversationHasMoreMessages,
hasConversationOpen,
current, current,
currentContactName, currentContactName,
currentBCC, currentBCC,

View File

@@ -15,14 +15,15 @@ export const useUserStore = defineStore('user', () => {
avatar_url: '', avatar_url: '',
email: '', email: '',
teams: [], teams: [],
permissions: [] permissions: [],
availability_status: 'offline'
}) })
const emitter = useEmitter() const emitter = useEmitter()
const userID = computed(() => user.value.id) const userID = computed(() => user.value.id)
const firstName = computed(() => user.value.first_name) const firstName = computed(() => user.value.first_name || '')
const lastName = computed(() => user.value.last_name) const lastName = computed(() => user.value.last_name || '')
const avatar = computed(() => user.value.avatar_url) const avatar = computed(() => user.value.avatar_url || '')
const permissions = computed(() => user.value.permissions || []) const permissions = computed(() => user.value.permissions || [])
const email = computed(() => user.value.email) const email = computed(() => user.value.email)
const teams = computed(() => user.value.teams || []) const teams = computed(() => user.value.teams || [])
@@ -71,6 +72,10 @@ export const useUserStore = defineStore('user', () => {
} }
} }
const setCurrentUser = (userData) => {
user.value = userData
}
const setAvatar = (avatarURL) => { const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') { if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string') console.warn('Avatar URL must be a string')
@@ -83,6 +88,16 @@ export const useUserStore = defineStore('user', () => {
user.value.avatar_url = '' user.value.avatar_url = ''
} }
const updateUserAvailability = async (status, isManual = true) => {
try {
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
await api.updateCurrentUserAvailability({ status: apiStatus })
user.value.availability_status = apiStatus
} catch (error) {
if (error?.response?.status === 401) window.location.href = '/'
}
}
return { return {
user, user,
userID, userID,
@@ -96,9 +111,11 @@ export const useUserStore = defineStore('user', () => {
getInitials, getInitials,
hasAdminTabPermissions, hasAdminTabPermissions,
hasReportTabPermissions, hasReportTabPermissions,
setCurrentUser,
getCurrentUser, getCurrentUser,
clearAvatar, clearAvatar,
setAvatar, setAvatar,
updateUserAvailability,
can can
} }
}) })

View File

@@ -0,0 +1,7 @@
export function debounce (fn, delay) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup> <script setup>
import { watch, onMounted } from 'vue' import { watch, onMounted, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import Conversation from '@/features/conversation/Conversation.vue' import Conversation from '@/features/conversation/Conversation.vue'
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue' import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
@@ -37,6 +37,10 @@ onMounted(() => {
if (props.uuid) fetchConversation(props.uuid) if (props.uuid) fetchConversation(props.uuid)
}) })
onUnmounted(() => {
conversationStore.resetCurrentConversation()
})
// Watcher for UUID changes // Watcher for UUID changes
watch( watch(
() => props.uuid, () => props.uuid,

View File

@@ -1,5 +1,5 @@
<template> <template>
<ConversationPlaceholder v-if="route.name === 'inbox'" /> <ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
<router-view /> <router-view />
</template> </template>

View File

@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter() const emitter = useEmitter()
const errorMessage = ref('') const errorMessage = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({ const loginForm = ref({
email: '', email: '',
password: '' password: ''
@@ -207,7 +209,10 @@ const loginAction = () => {
email: loginForm.value.email, email: loginForm.value.email,
password: loginForm.value.password password: loginForm.value.password
}) })
.then(() => { .then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' }) router.push({ name: 'inboxes' })
}) })
.catch((error) => { .catch((error) => {

View File

@@ -76,8 +76,7 @@
</main> </main>
<footer class="p-6 text-center"> <footer class="p-6 text-center">
<div class="text-sm text-muted-foreground space-x-4"> <div class="text-sm text-muted-foreground space-x-4"></div>
</div>
</footer> </footer>
</div> </div>
</template> </template>
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error' import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
const errorMessage = ref('') const errorMessage = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const emitter = useEmitter()
const router = useRouter() const router = useRouter()
const resetForm = ref({ const resetForm = ref({
email: '' email: ''
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
await api.resetPassword({ await api.resetPassword({
email: resetForm.value.email email: resetForm.value.email
}) })
toast({ emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent', title: 'Reset link sent',
description: 'Please check your email for the reset link.' description: 'Please check your email for the reset link.'
}) })
router.push({ name: 'login' }) router.push({ name: 'login' })
} catch (err) { } catch (err) {
toast({ emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error', title: 'Reset link sent',
description: err.response.data.message, variant: 'destructive',
variant: 'destructive' description: handleHTTPError(err).message
}) })
errorMessage.value = handleHTTPError(err).message errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake') useTemporaryClass('reset-password-container', 'animate-shake')

View File

@@ -125,18 +125,16 @@ onMounted(() => {
}) })
const validateForm = () => { const validateForm = () => {
if (!passwordForm.value.password || passwordForm.value.password.length < 8) { if (!passwordForm.value.password) {
errorMessage.value = 'Password must be at least 8 characters long.' errorMessage.value = 'Password is required.'
useTemporaryClass('set-password-container', 'animate-shake') useTemporaryClass('set-password-container', 'animate-shake')
return false return false
} }
if (passwordForm.value.password !== passwordForm.value.confirmPassword) { if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = 'Passwords do not match.' errorMessage.value = 'Passwords do not match.'
useTemporaryClass('set-password-container', 'animate-shake') useTemporaryClass('set-password-container', 'animate-shake')
return false return false
} }
return true return true
} }
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
}) })
router.push({ name: 'login' }) router.push({ name: 'login' })
} catch (err) { } catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake') useTemporaryClass('set-password-container', 'animate-shake')
} finally { } finally {

View File

@@ -1,27 +1,29 @@
<template> <template>
<div <div class="overflow-y-auto">
class="overflow-y-auto p-4 pr-36" <div
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }" class="p-4 w-[calc(100%-3rem)]"
> :class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
<Spinner v-if="isLoading" /> >
<div class="space-y-4"> <Spinner v-if="isLoading" />
<div class="text-sm text-gray-500 text-right"> <div class="space-y-4">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }} <div class="text-sm text-gray-500 text-right">
</div> Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
<div class="mt-7 flex w-full space-x-4"> </div>
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" /> <div class="mt-7 flex w-full space-x-4">
<Card <Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
class="w-8/12" <Card
title="Agent status" class="w-8/12"
:counts="sampleAgentStatusCounts" title="Agent status"
:labels="sampleAgentStatusLabels" :counts="agentStatusCounts"
/> :labels="agentStatusLabels"
</div> />
<div class="rounded-lg box w-full p-5 bg-white"> </div>
<LineChart :data="chartData.processedData"></LineChart> <div class="rounded-lg box w-full p-5 bg-white">
</div> <LineChart :data="chartData.processedData"></LineChart>
<div class="rounded-lg box w-full p-5 bg-white"> </div>
<BarChart :data="chartData.status_summary"></BarChart> <div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
pending: 'Pending' pending: 'Pending'
} }
// TODO: Build agent status feature. const agentStatusLabels = {
const sampleAgentStatusLabels = { agents_online: 'Online',
online: 'Online', agents_offline: 'Offline',
offline: 'Offline', agents_away: 'Away'
away: 'Away'
}
const sampleAgentStatusCounts = {
online: 5,
offline: 2,
away: 1
} }
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0
})
onMounted(() => { onMounted(() => {
getDashboardData() getDashboardData()
startRealtimeUpdates() startRealtimeUpdates()
@@ -96,6 +98,11 @@ const getCardStats = async () => {
.getOverviewCounts() .getOverviewCounts()
.then((resp) => { .then((resp) => {
cardCounts.value = resp.data.data cardCounts.value = resp.data.data
agentStatusCounts.value = {
agents_online: cardCounts.value.agents_online,
agents_offline: cardCounts.value.agents_offline,
agents_away: cardCounts.value.agents_away
}
}) })
.catch((error) => { .catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -1,4 +1,5 @@
const animate = require("tailwindcss-animate") const animate = require("tailwindcss-animate")
const typography = require("@tailwindcss/typography")
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
@@ -140,5 +141,5 @@ module.exports = {
}, },
}, },
}, },
plugins: [animate], plugins: [animate, typography],
} }

View File

@@ -234,7 +234,10 @@ SELECT json_build_object(
'open', COUNT(*), 'open', COUNT(*),
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END), 'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END), 'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END) 'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
) )
FROM conversations c FROM conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id INNER JOIN conversation_statuses s ON c.status_id = s.id

View File

@@ -0,0 +1,22 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_3_0 updates the database schema to v0.3.0.
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
END IF;
END$$;
ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
`)
return err
}

View File

@@ -8,29 +8,37 @@ import (
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
) )
var (
Online = "online"
Offline = "offline"
Away = "away"
AwayManual = "away_manual"
)
type User struct { type User struct {
ID int `db:"id" json:"id,omitempty"` ID int `db:"id" json:"id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"` FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email,omitempty"` Email null.String `db:"email" json:"email,omitempty"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"` AvailabilityStatus string `db:"availability_status" json:"availability_status"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"` PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
Enabled bool `db:"enabled" json:"enabled"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Password string `db:"password" json:"-"` Enabled bool `db:"enabled" json:"enabled"`
Roles pq.StringArray `db:"roles" json:"roles,omitempty"` Password string `db:"password" json:"-"`
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"` Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
Meta pq.StringArray `db:"meta" json:"meta,omitempty"` Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"` Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"` CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"` Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
NewPassword string `db:"-" json:"new_password,omitempty"` ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"` NewPassword string `db:"-" json:"new_password,omitempty"`
InboxID int `json:"-"` SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
SourceChannel null.String `json:"-"` InboxID int `json:"-"`
SourceChannelID null.String `json:"-"` SourceChannel null.String `json:"-"`
SourceChannelID null.String `json:"-"`
} }
func (u *User) FullName() string { func (u *User) FullName() string {

View File

@@ -20,41 +20,32 @@ SELECT email
FROM users FROM users
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent'; WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
-- name: get-user-by-email
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
array_agg(DISTINCT r.name) as roles,
array_agg(DISTINCT p) as permissions
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
GROUP BY u.id;
-- name: get-user -- name: get-user
SELECT SELECT
u.id, u.id,
u.created_at, u.email,
u.updated_at, u.password,
u.enabled, u.created_at,
u.email, u.updated_at,
u.avatar_url, u.enabled,
u.first_name, u.avatar_url,
u.last_name, u.first_name,
array_agg(DISTINCT r.name) as roles, u.last_name,
COALESCE( u.availability_status,
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) array_agg(DISTINCT r.name) as roles,
FROM team_members tm COALESCE(
JOIN teams t ON tm.team_id = t.id (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
WHERE tm.user_id = u.id), FROM team_members tm
'[]' JOIN teams t ON tm.team_id = t.id
) AS teams, WHERE tm.user_id = u.id),
array_agg(DISTINCT p) as permissions '[]'
) AS teams,
array_agg(DISTINCT p) as permissions
FROM users u FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id, LEFT JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p unnest(r.permissions) p
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent' WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
GROUP BY u.id; GROUP BY u.id;
-- name: set-user-password -- name: set-user-password
@@ -92,6 +83,22 @@ UPDATE users
SET avatar_url = $2, updated_at = now() SET avatar_url = $2, updated_at = now()
WHERE id = $1 AND type = 'agent'; WHERE id = $1 AND type = 'agent';
-- name: update-availability
UPDATE users
SET availability_status = $2
WHERE id = $1;
-- name: update-last-active-at
UPDATE users
SET last_active_at = now(),
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
WHERE id = $1;
-- name: update-inactive-offline
UPDATE users
SET availability_status = 'offline'
WHERE last_active_at < now() - interval '5 minutes' and availability_status != 'offline';
-- name: get-permissions -- name: get-permissions
SELECT DISTINCT unnest(r.permissions) SELECT DISTINCT unnest(r.permissions)
FROM users u FROM users u

View File

@@ -10,6 +10,7 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"time"
"log" "log"
@@ -61,13 +62,15 @@ type Opts struct {
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
type queries struct { type queries struct {
GetUsers *sqlx.Stmt `query:"get-users"` GetUsers *sqlx.Stmt `query:"get-users"`
GetUserCompact *sqlx.Stmt `query:"get-users-compact"` GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
GetUser *sqlx.Stmt `query:"get-user"` GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"` GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"` GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"` UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"` UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"` SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"` SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"` SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
}, nil }, nil
} }
// VerifyPassword authenticates a user by email and password. // VerifyPassword authenticates an user by email and password.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) { func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
} }
u.lo.Error("error fetching user from db", "error", err) u.lo.Error("error fetching user from db", "error", err)
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil) return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.entities.user}"), nil)
} }
if err := u.verifyPassword(password, user.Password); err != nil { if err := u.verifyPassword(password, user.Password); err != nil {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
} }
return user, nil return user, nil
} }
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
// GetAllCompact returns a compact list of users with limited fields. // GetAllCompact returns a compact list of users with limited fields.
func (u *Manager) GetAllCompact() ([]models.User, error) { func (u *Manager) GetAllCompact() ([]models.User, error) {
var users = make([]models.User, 0) var users = make([]models.User, 0)
if err := u.q.GetUserCompact.Select(&users); err != nil { if err := u.q.GetUsersCompact.Select(&users); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return users, nil return users, nil
} }
@@ -154,10 +154,10 @@ func (u *Manager) CreateAgent(user *models.User) error {
return nil return nil
} }
// Get retrieves a user by ID. // Get retrieves an user by ID.
func (u *Manager) Get(id int) (models.User, error) { func (u *Manager) Get(id int) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUser.Get(&user, id); err != nil { if err := u.q.GetUser.Get(&user, id, ""); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
u.lo.Error("user not found", "id", id, "error", err) u.lo.Error("user not found", "id", id, "error", err)
return user, envelope.NewError(envelope.GeneralError, "User not found", nil) return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
@@ -168,10 +168,10 @@ func (u *Manager) Get(id int) (models.User, error) {
return user, nil return user, nil
} }
// GetByEmail retrieves a user by email // GetByEmail retrieves an user by email
func (u *Manager) GetByEmail(email string) (models.User, error) { func (u *Manager) GetByEmail(email string) (models.User, error) {
var user models.User var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil { if err := u.q.GetUser.Get(&user, 0, email); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.GeneralError, "User not found", nil) return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
} }
@@ -195,10 +195,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
return nil return nil
} }
// Update updates a user. // Update updates an user.
func (u *Manager) Update(id int, user models.User) error { func (u *Manager) Update(id int, user models.User) error {
var ( var (
hashedPassword interface{} hashedPassword any
err error err error
) )
@@ -221,7 +221,7 @@ func (u *Manager) Update(id int, user models.User) error {
return nil return nil
} }
// SoftDelete soft deletes a user. // SoftDelete soft deletes an user.
func (u *Manager) SoftDelete(id int) error { func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user. // Disallow if user is system user.
systemUser, err := u.GetSystemUser() systemUser, err := u.GetSystemUser()
@@ -239,7 +239,7 @@ func (u *Manager) SoftDelete(id int) error {
return nil return nil
} }
// GetEmail retrieves the email of a user by ID. // GetEmail retrieves the email of an user by ID.
func (u *Manager) GetEmail(id int) (string, error) { func (u *Manager) GetEmail(id int) (string, error) {
var email string var email string
if err := u.q.GetEmail.Get(&email, id); err != nil { if err := u.q.GetEmail.Get(&email, id); err != nil {
@@ -252,7 +252,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
return email, nil return email, nil
} }
// SetResetPasswordToken sets a reset password token for a user and returns the token. // SetResetPasswordToken sets a reset password token for an user and returns the token.
func (u *Manager) SetResetPasswordToken(id int) (string, error) { func (u *Manager) SetResetPasswordToken(id int) (string, error) {
token, err := stringutil.RandomAlphanumeric(32) token, err := stringutil.RandomAlphanumeric(32)
if err != nil { if err != nil {
@@ -266,7 +266,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
return token, nil return token, nil
} }
// ResetPassword sets a new password for a user. // ResetPassword sets a new password for an user.
func (u *Manager) ResetPassword(token, password string) error { func (u *Manager) ResetPassword(token, password string) error {
if !u.isStrongPassword(password) { if !u.isStrongPassword(password) {
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil) return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
@@ -277,14 +277,18 @@ func (u *Manager) ResetPassword(token, password string) error {
u.lo.Error("error generating bcrypt password", "error", err) u.lo.Error("error generating bcrypt password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil) return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
} }
if _, err := u.q.ResetPassword.Exec(passwordHash, token); err != nil { rows, err := u.q.ResetPassword.Exec(passwordHash, token)
if err != nil {
u.lo.Error("error setting new password", "error", err) u.lo.Error("error setting new password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil) return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
} }
if count, _ := rows.RowsAffected(); count == 0 {
return envelope.NewError(envelope.InputError, "Token is invalid or expired, please try again by requesting a new password reset link", nil)
}
return nil return nil
} }
// GetPermissions retrieves the permissions of a user by ID. // GetPermissions retrieves the permissions of an user by ID.
func (u *Manager) GetPermissions(id int) ([]string, error) { func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string var permissions []string
if err := u.q.GetPermissions.Select(&permissions, id); err != nil { if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
@@ -294,6 +298,52 @@ func (u *Manager) GetPermissions(id int) ([]string, error) {
return permissions, nil return permissions, nil
} }
// UpdateAvailability updates the availability status of an user.
func (u *Manager) UpdateAvailability(id int, status string) error {
if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil {
u.lo.Error("error updating user availability", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user availability", nil)
}
return nil
}
// UpdateLastActive updates the last active timestamp of an user.
func (u *Manager) UpdateLastActive(id int) error {
if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
u.lo.Error("error updating user last active at", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user last active at", nil)
}
return nil
}
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
u.markInactiveAgentsOffline()
case <-ctx.Done():
return
}
}
}
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
func (u *Manager) markInactiveAgentsOffline() {
u.lo.Debug("marking inactive agents offline")
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
u.lo.Error("error setting users offline", "error", err)
} else {
rows, _ := res.RowsAffected()
if rows > 0 {
u.lo.Info("set inactive users offline", "count", rows)
}
}
u.lo.Debug("marked inactive agents offline")
}
// verifyPassword compares the provided password with the stored password hash. // verifyPassword compares the provided password with the stored password hash.
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {

View File

@@ -94,7 +94,9 @@ func (c *Client) Listen() {
// processIncomingMessage processes incoming messages from the client. // processIncomingMessage processes incoming messages from the client.
func (c *Client) processIncomingMessage(data []byte) { func (c *Client) processIncomingMessage(data []byte) {
// Handle ping messages, and update last active time for user.
if string(data) == "ping" { if string(data) == "ping" {
c.Hub.userStore.UpdateLastActive(c.ID)
c.SendMessage([]byte("pong"), websocket.TextMessage) c.SendMessage([]byte("pong"), websocket.TextMessage)
return return
} }

View File

@@ -13,13 +13,20 @@ type Hub struct {
// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client. // Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
clients map[int][]*Client clients map[int][]*Client
clientsMutex sync.Mutex clientsMutex sync.Mutex
userStore userStore
}
type userStore interface {
UpdateLastActive(userID int) error
} }
// NewHub creates a new websocket hub. // NewHub creates a new websocket hub.
func NewHub() *Hub { func NewHub(userStore userStore) *Hub {
return &Hub{ return &Hub{
clients: make(map[int][]*Client, 10000), clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{}, clientsMutex: sync.Mutex{},
userStore: userStore,
} }
} }

View File

@@ -13,6 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user'); DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment'); DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs'); DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
-- Sequence to generate reference number for conversations. -- Sequence to generate reference number for conversations.
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100; DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
@@ -118,6 +119,8 @@ CREATE TABLE users (
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
reset_password_token TEXT NULL, reset_password_token TEXT NULL,
reset_password_token_expiry TIMESTAMPTZ NULL, reset_password_token_expiry TIMESTAMPTZ NULL,
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_at TIMESTAMPTZ NULL,
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140), CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20), CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320), CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),