feat: show contact offline / online status on sidebar

- mark contacts offline if not active for > 5 mins.
This commit is contained in:
Abhinav Raut
2026-01-26 03:28:34 +05:30
parent 70e6ebcc48
commit c155fce93b
10 changed files with 150 additions and 41 deletions
+5 -1
View File
@@ -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)
+6
View File
@@ -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'
@@ -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>
+17 -2
View File
@@ -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>
+13
View File
@@ -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
View File
@@ -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.
+8 -4
View File
@@ -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
+10
View File
@@ -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 {