mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
feat: show contact offline / online status on sidebar
- mark contacts offline if not active for > 5 mins.
This commit is contained in:
+5
-1
@@ -241,7 +241,11 @@ func main() {
|
||||
go sla.Run(ctx, slaEvaluationInterval)
|
||||
go sla.SendNotifications(ctx)
|
||||
go media.DeleteUnlinkedMedia(ctx)
|
||||
go user.MonitorAgentAvailability(ctx)
|
||||
go user.MonitorUserAvailability(ctx, func(userIDs []int) {
|
||||
for _, id := range userIDs {
|
||||
conversation.BroadcastContactStatus(id, "offline")
|
||||
}
|
||||
})
|
||||
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
|
||||
go userNotification.RunNotificationCleaner(ctx)
|
||||
|
||||
|
||||
@@ -104,10 +104,16 @@ func handleWidgetWS(r *fastglue.Request) error {
|
||||
if msg.JWT != "" && inboxID != 0 {
|
||||
if claims, err := validateWidgetMessageJWT(app, msg.JWT, inboxID); err == nil {
|
||||
if userID, err := resolveUserIDFromClaims(app, claims); err == nil {
|
||||
// Check if user was offline before updating
|
||||
wasOffline := app.user.IsOffline(userID)
|
||||
if err := app.user.UpdateLastActive(userID); err != nil {
|
||||
app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err)
|
||||
} else {
|
||||
app.lo.Debug("updated user last active timestamp", "user_id", userID)
|
||||
// Broadcast online status if user just came online
|
||||
if wasOffline {
|
||||
app.conversation.BroadcastContactStatus(userID, "online")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,19 @@
|
||||
size="md"
|
||||
class="p-0"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded relative overflow-visible">
|
||||
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
|
||||
<AvatarFallback class="rounded">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
|
||||
:class="{
|
||||
'bg-green-500': userStore.user.availability_status === 'online',
|
||||
'bg-amber-500':
|
||||
userStore.user.availability_status === 'away' ||
|
||||
userStore.user.availability_status === 'away_manual' ||
|
||||
userStore.user.availability_status === 'away_and_reassigning',
|
||||
'bg-gray-400': userStore.user.availability_status === 'offline'
|
||||
}"
|
||||
></div>
|
||||
</Avatar>
|
||||
<div class="relative">
|
||||
<Avatar class="h-8 w-8 rounded">
|
||||
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
|
||||
<AvatarFallback class="rounded">
|
||||
{{ userStore.getInitials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<StatusDot
|
||||
:status="userStore.user.availability_status"
|
||||
size="md"
|
||||
class="absolute bottom-0 right-0 border border-background"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
|
||||
<span class="truncate text-xs">{{ userStore.email }}</span>
|
||||
@@ -121,6 +117,7 @@ import {
|
||||
} from '@shared-ui/components/ui/dropdown-menu'
|
||||
import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import StatusDot from '@shared-ui/components/StatusDot.vue'
|
||||
import { Switch } from '@shared-ui/components/ui/switch'
|
||||
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
+18
-6
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-start">
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="conversation?.contact?.avatar_url || ''" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.contact?.first_name?.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="relative">
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage :src="conversation?.contact?.avatar_url || ''" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.contact?.first_name?.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<StatusDot
|
||||
v-if="isLivechat"
|
||||
:status="contactStatus"
|
||||
size="lg"
|
||||
class="absolute bottom-1 right-1 border-2 border-background"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -79,6 +87,7 @@ import { computed } from 'vue'
|
||||
import { ViewVerticalIcon } from '@radix-icons/vue'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
|
||||
import StatusDot from '@shared-ui/components/StatusDot.vue'
|
||||
import { Mail, Phone, ExternalLink, AlertCircle, IdCard } from 'lucide-vue-next'
|
||||
import countries from '@/constants/countries.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
@@ -104,4 +113,7 @@ const phoneNumber = computed(() => {
|
||||
const callingCode = country ? country.calling_code : countryCodeValue
|
||||
return `${callingCode} ${number}`
|
||||
})
|
||||
|
||||
const isLivechat = computed(() => conversation.value?.inbox_channel === 'livechat')
|
||||
const contactStatus = computed(() => conversation.value?.contact?.availability_status)
|
||||
</script>
|
||||
|
||||
@@ -665,7 +665,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
/**
|
||||
* Update a conversation property, supports nested paths via dot notation
|
||||
* @param {Object} update - { uuid, prop, value }
|
||||
* @param {Object} update - { uuid, prop, value } or { contact_id, prop, value }
|
||||
*/
|
||||
function updateConversationProp (update) {
|
||||
const updateNested = (obj, prop, value) => {
|
||||
@@ -680,7 +680,22 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
target[lastKey] = value
|
||||
}
|
||||
|
||||
const { uuid, prop, value } = update
|
||||
const { uuid, contact_id, prop, value } = update
|
||||
|
||||
// Handle contact status updates (broadcast by contact_id)
|
||||
if (contact_id && prop === 'contact.availability_status') {
|
||||
// Update current conversation if it belongs to this contact
|
||||
if (conversation.data?.contact_id === contact_id) {
|
||||
updateNested(conversation.data, prop, value)
|
||||
}
|
||||
// Update conversations in the list that belong to this contact
|
||||
conversations?.data?.forEach(c => {
|
||||
if (c.contact_id === contact_id) {
|
||||
updateNested(c, prop, value)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Conversation is currently open? Update it.
|
||||
if (conversation.data?.uuid === uuid) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-full"
|
||||
:class="[sizeClass, statusClass]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: 'offline'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['sm', 'md', 'lg'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'h-2 w-2',
|
||||
md: 'h-2.5 w-2.5',
|
||||
lg: 'h-3.5 w-3.5'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'online':
|
||||
return 'bg-green-500'
|
||||
case 'away':
|
||||
case 'away_manual':
|
||||
case 'away_and_reassigning':
|
||||
return 'bg-amber-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -53,6 +53,19 @@ func (m *Manager) BroadcastConversationUpdate(conversationUUID, prop string, val
|
||||
m.broadcastToUsers([]int{}, message)
|
||||
}
|
||||
|
||||
// BroadcastContactStatus broadcasts a contact's availability status to all agents.
|
||||
func (m *Manager) BroadcastContactStatus(contactID int, status string) {
|
||||
message := wsmodels.Message{
|
||||
Type: wsmodels.MessageTypeConversationPropertyUpdate,
|
||||
Data: map[string]interface{}{
|
||||
"contact_id": contactID,
|
||||
"prop": "contact.availability_status",
|
||||
"value": status,
|
||||
},
|
||||
}
|
||||
m.broadcastToUsers([]int{}, message)
|
||||
}
|
||||
|
||||
// BroadcastTypingToConversation broadcasts typing status to all subscribers of a conversation.
|
||||
// Set broadcastToWidgets to false when the typing event originates from a widget client to avoid echo.
|
||||
func (m *Manager) BroadcastTypingToConversation(conversationUUID string, isTyping bool, broadcastToWidgets bool) {
|
||||
|
||||
+15
-11
@@ -13,14 +13,16 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
|
||||
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
|
||||
// MonitorUserAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
|
||||
func (u *Manager) MonitorUserAvailability(ctx context.Context, onUsersOffline func([]int)) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
u.markInactiveAgentsOffline()
|
||||
if userIDs := u.MarkInactiveUsersOffline(); len(userIDs) > 0 && onUsersOffline != nil {
|
||||
onUsersOffline(userIDs)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -154,16 +156,18 @@ func (u *Manager) SoftDeleteAgent(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
|
||||
func (u *Manager) markInactiveAgentsOffline() {
|
||||
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
|
||||
// MarkInactiveUsersOffline sets users offline if they have been inactive for more than 5 minutes.
|
||||
// Returns the IDs of users that were marked offline.
|
||||
func (u *Manager) MarkInactiveUsersOffline() []int {
|
||||
var userIDs []int
|
||||
if err := u.q.UpdateInactiveOffline.Select(&userIDs); 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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
u.lo.Info("set inactive users offline", "count", len(userIDs))
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
// GetAllAgents returns a list of all agents.
|
||||
|
||||
@@ -126,10 +126,14 @@ WHERE id = $1;
|
||||
-- name: update-inactive-offline
|
||||
UPDATE users
|
||||
SET availability_status = 'offline'
|
||||
WHERE
|
||||
type = 'agent'
|
||||
AND (last_active_at IS NULL OR last_active_at < NOW() - INTERVAL '5 minutes')
|
||||
AND availability_status NOT IN ('offline', 'away_and_reassigning', 'away_manual');
|
||||
WHERE
|
||||
type IN ('agent', 'contact', 'visitor')
|
||||
AND (last_active_at IS NULL OR last_active_at < NOW() - INTERVAL '5 minutes')
|
||||
AND availability_status NOT IN ('offline', 'away_and_reassigning', 'away_manual')
|
||||
RETURNING id;
|
||||
|
||||
-- name: get-availability-status
|
||||
SELECT availability_status FROM users WHERE id = $1;
|
||||
|
||||
-- name: set-reset-password-token
|
||||
UPDATE users
|
||||
|
||||
@@ -74,6 +74,7 @@ type queries struct {
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
|
||||
GetAvailabilityStatus *sqlx.Stmt `query:"get-availability-status"`
|
||||
UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
|
||||
SoftDeleteAgent *sqlx.Stmt `query:"soft-delete-agent"`
|
||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||
@@ -261,6 +262,15 @@ func (u *Manager) UpdateLastActive(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOffline returns true if the user's availability status is offline.
|
||||
func (u *Manager) IsOffline(id int) bool {
|
||||
var status string
|
||||
if err := u.q.GetAvailabilityStatus.Get(&status, id); err != nil {
|
||||
return true
|
||||
}
|
||||
return status == "offline"
|
||||
}
|
||||
|
||||
// SaveCustomAttributes sets or merges custom attributes for a user.
|
||||
// If replace is true, existing attributes are overwritten. Otherwise, attributes are merged.
|
||||
func (u *Manager) SaveCustomAttributes(id int, customAttributes map[string]any, replace bool) error {
|
||||
|
||||
Reference in New Issue
Block a user