ultimatepos/Modules/Communication/Resources/js/components/ChatApp.vue

631 lines
25 KiB
Vue

<script setup>
import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { getConfig, getLang } from '../utils/config';
import { fullName, initials, formatTime, displayName } from '../utils/format';
import * as chatApi from '../api/chat';
import { REALTIME_KEY } from '../composables/useRealtime';
import ChatComposer from './ChatComposer.vue';
const config = getConfig();
const currentUserId = config.userId;
const typingTimeout = config.typingTimeoutMs || 2500;
const pollingFallbackMs = (config.pollingFallbackSeconds || 30) * 1000;
const colleagues = ref([]);
const rooms = ref([]);
const roomsLoading = ref(false);
const roomFilter = ref('all');
const roomSearch = ref('');
const messageSearch = ref('');
const currentRoomId = ref(null);
const currentRoom = ref(null);
const messages = ref([]);
const typingText = ref('');
const roomOpen = ref(false);
const sending = ref(false);
const showDirectModal = ref(false);
const showGroupModal = ref(false);
const showMembersModal = ref(false);
const directUserId = ref('');
const groupName = ref('');
const groupMemberIds = ref([]);
const addMemberUserId = ref('');
let typingClearTimer = null;
let lastTypingSent = 0;
let fallbackPollTimer = null;
let roomSearchTimer = null;
const messageBox = ref(null);
const composerRef = ref(null);
const realtime = inject(REALTIME_KEY);
let unregisterRealtime = null;
function handleMessage(payload, roomId) {
const msg = payload?.message;
if (!msg) return;
updateRoomPreview(roomId, msg);
if (parseInt(currentRoomId.value, 10) === parseInt(roomId, 10)) {
appendMessage(msg);
chatApi.markRead(roomId).catch(() => {});
} else {
bumpUnread(roomId);
}
}
function handleTyping(payload, roomId) {
const senderId = parseInt(payload?.user_id, 10);
if (!senderId || senderId === parseInt(currentUserId, 10)) return;
if (parseInt(currentRoomId.value, 10) !== parseInt(roomId, 10)) return;
const name = payload.user_name || fullName(payload.user) || getLang('group', 'User');
typingText.value = getLang('user_typing', ':name is typing...').replace(':name', name);
clearTimeout(typingClearTimer);
typingClearTimer = setTimeout(() => { typingText.value = ''; }, typingTimeout);
}
function handleConnected() {
stopFallbackPolling();
}
function handleDisconnected() {
startFallbackPolling();
}
function roomDisplayName(room) {
if (room.type === 'group') return room.name || getLang('group', 'Group');
const other = (room.members || []).find((m) => m.id !== currentUserId);
return other ? fullName(other) : getLang('group', 'Group');
}
function isOtherOnline(room) {
if (room.is_other_online !== undefined) return room.is_other_online;
const other = (room.members || []).find((m) => m.id !== currentUserId);
return other ? !!other.is_online : false;
}
const chatSubtitle = computed(() => {
if (!currentRoom.value) return '';
if (currentRoom.value.type === 'direct') {
return isOtherOnline(currentRoom.value) ? getLang('online') : getLang('offline');
}
return `${(currentRoom.value.members || []).length} ${getLang('members', 'members')}`;
});
const isGroupChat = computed(() => currentRoom.value?.type === 'group');
function roomPreview(room) {
const body = (room.latest_message || {}).body || '';
if (!body) return '';
if (room.type === 'group' && room.latest_message?.user) {
const sender = displayName(room.latest_message.user, getLang('you'));
return `${sender}: ${body}`;
}
return body;
}
function messageLabel(msg) {
const mine = parseInt(msg.user_id, 10) === parseInt(currentUserId, 10);
if (mine) return getLang('you');
return displayName(msg.user);
}
function showMessageName(msg) {
return isGroupChat.value || parseInt(msg.user_id, 10) !== parseInt(currentUserId, 10);
}
function colleagueLabel(user) {
const name = fullName(user);
const handle = user.username ? ` (@${user.username})` : '';
return `${name}${handle}${user.is_online ? ' ●' : ''}`;
}
const canManageMembers = computed(() => currentRoom.value?.created_by === currentUserId);
const availableColleaguesForAdd = computed(() => {
const existing = new Set((currentRoom.value?.members || []).map((m) => m.id));
return colleagues.value.filter((c) => !existing.has(c.id));
});
function notifyError(message) {
if (typeof window.toastr !== 'undefined') {
window.toastr.error(message);
} else {
alert(message);
}
}
function notifySuccess(message) {
if (typeof window.toastr !== 'undefined') {
window.toastr.success(message);
}
}
function updateRoomPreview(roomId, msg) {
rooms.value = rooms.value.map((r) => {
if (parseInt(r.id, 10) !== parseInt(roomId, 10)) return r;
return {
...r,
latest_message: msg,
last_message_at: msg.created_at,
};
});
}
function bumpUnread(roomId) {
rooms.value = rooms.value.map((r) => {
if (parseInt(r.id, 10) !== parseInt(roomId, 10)) return r;
return { ...r, unread_count: (r.unread_count || 0) + 1 };
});
}
function appendMessage(msg) {
if (!msg?.body) return;
if (messages.value.some((m) => m.id && msg.id && m.id === msg.id)) return;
messages.value.push(msg);
scrollToBottom();
}
async function scrollToBottom() {
await nextTick();
if (messageBox.value) {
messageBox.value.scrollTop = messageBox.value.scrollHeight;
}
}
async function loadColleagues() {
colleagues.value = await chatApi.fetchColleagues();
}
async function loadRooms() {
if (roomsLoading.value) return;
roomsLoading.value = true;
try {
const data = await chatApi.fetchRooms({
q: roomSearch.value.trim(),
type: roomFilter.value === 'all' ? '' : roomFilter.value,
});
rooms.value = data || [];
realtime.syncRoomSubscriptions(rooms.value.map((r) => r.id));
} finally {
roomsLoading.value = false;
}
}
async function loadMessages(roomId) {
const params = messageSearch.value.trim() ? { search: messageSearch.value.trim() } : {};
messages.value = await chatApi.fetchMessages(roomId, params);
await scrollToBottom();
}
async function openRoom(room) {
currentRoomId.value = room.id;
currentRoom.value = room;
roomOpen.value = true;
await loadMessages(room.id);
await chatApi.markRead(room.id);
rooms.value = rooms.value.map((r) => (
parseInt(r.id, 10) === parseInt(room.id, 10) ? { ...r, unread_count: 0 } : r
));
await nextTick();
composerRef.value?.focus();
}
async function selectRoom(room) {
try {
if (!room.members?.length) {
room = await chatApi.fetchRoom(room.id);
}
await openRoom(room);
} catch {
notifyError(getLang('send_failed'));
}
}
function closeRoom() {
roomOpen.value = false;
}
async function sendChatMessage(body) {
if (!body.trim() || !currentRoomId.value || sending.value) return;
sending.value = true;
try {
const msg = await chatApi.sendMessage(currentRoomId.value, body.trim());
if (msg?.body) {
appendMessage(msg);
updateRoomPreview(currentRoomId.value, msg);
} else {
await loadMessages(currentRoomId.value);
}
} catch (err) {
notifyError(err.response?.data?.message || getLang('send_failed'));
} finally {
sending.value = false;
}
}
const composerText = ref('');
async function onSubmitMessage() {
const text = composerText.value;
if (!text.trim()) return;
composerText.value = '';
await sendChatMessage(text);
composerRef.value?.focus();
}
function onComposerTyping() {
if (!currentRoomId.value) return;
const now = Date.now();
if (now - lastTypingSent < 1500) return;
lastTypingSent = now;
realtime?.whisperTyping?.(currentRoomId.value);
}
async function createDirectRoom() {
if (!directUserId.value) return;
try {
const room = await chatApi.createRoom({ type: 'direct', user_id: directUserId.value });
showDirectModal.value = false;
directUserId.value = '';
await loadRooms();
if (room?.id) await selectRoom(room);
} catch (err) {
notifyError(err.response?.data?.message || getLang('send_failed'));
}
}
async function createGroupRoom() {
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
try {
const room = await chatApi.createRoom({
type: 'group',
name: groupName.value.trim(),
member_ids: groupMemberIds.value,
});
showGroupModal.value = false;
groupName.value = '';
groupMemberIds.value = [];
await loadRooms();
if (room?.id) await selectRoom(room);
} catch (err) {
notifyError(err.response?.data?.message || getLang('send_failed'));
}
}
async function openMembersModal() {
if (!currentRoom.value && currentRoomId.value) {
currentRoom.value = await chatApi.fetchRoom(currentRoomId.value);
}
showMembersModal.value = true;
}
async function addMember() {
if (!addMemberUserId.value || !currentRoomId.value) return;
try {
currentRoom.value = await chatApi.addMember(currentRoomId.value, addMemberUserId.value);
addMemberUserId.value = '';
await loadRooms();
} catch (err) {
notifyError(err.response?.data?.message || getLang('send_failed'));
}
}
async function removeMember(userId) {
if (!currentRoomId.value) return;
try {
const room = await chatApi.removeMember(currentRoomId.value, userId);
if (parseInt(userId, 10) === parseInt(currentUserId, 10)) {
showMembersModal.value = false;
currentRoomId.value = null;
currentRoom.value = null;
roomOpen.value = false;
messages.value = [];
} else {
currentRoom.value = room;
}
await loadRooms();
} catch (err) {
notifyError(err.response?.data?.message || getLang('send_failed'));
}
}
function startFallbackPolling() {
if (fallbackPollTimer) return;
fallbackPollTimer = setInterval(() => {
chatApi.sendHeartbeat().finally(loadRooms);
}, pollingFallbackMs);
}
function stopFallbackPolling() {
if (!fallbackPollTimer) return;
clearInterval(fallbackPollTimer);
fallbackPollTimer = null;
}
watch(roomSearch, () => {
clearTimeout(roomSearchTimer);
roomSearchTimer = setTimeout(loadRooms, 300);
});
watch(roomFilter, () => loadRooms());
watch(messageSearch, () => {
clearTimeout(roomSearchTimer);
roomSearchTimer = setTimeout(() => {
if (currentRoomId.value) loadMessages(currentRoomId.value);
}, 300);
});
onMounted(async () => {
unregisterRealtime = realtime?.register?.({
onMessage: handleMessage,
onTyping: handleTyping,
onConnected: handleConnected,
onDisconnected: handleDisconnected,
});
await loadColleagues();
await loadRooms();
if (realtime?.isRealtimeEnabled?.()) {
if (!realtime.connected?.value) {
setTimeout(() => {
if (!realtime.connected?.value) startFallbackPolling();
}, 5000);
}
} else {
startFallbackPolling();
}
});
onUnmounted(() => {
unregisterRealtime?.();
stopFallbackPolling();
clearTimeout(typingClearTimer);
clearTimeout(roomSearchTimer);
});
</script>
<template>
<div class="chat-shell" :class="{ 'room-open': roomOpen }">
<div class="chat-sidebar">
<div class="comm-card">
<div class="comm-card-header">
<h3 class="comm-card-title">{{ getLang('chat') }}</h3>
<div>
<button type="button" class="comm-btn comm-btn-ghost btn-sm" :title="getLang('new_direct_chat')" @click="showDirectModal = true">
<i class="fa fa-user-plus"></i>
</button>
<button type="button" class="comm-btn comm-btn-primary btn-sm" @click="showGroupModal = true">
<i class="fa fa-users"></i> {{ getLang('new_group') }}
</button>
</div>
</div>
<div style="padding: 12px 14px 0;">
<input v-model="roomSearch" type="text" class="comm-input" :placeholder="getLang('search_chats')">
<div class="comm-tabs" style="margin-top:10px;">
<button type="button" class="comm-tab" :class="{ active: roomFilter === 'all' }" @click="roomFilter = 'all'">{{ getLang('filter_all') }}</button>
<button type="button" class="comm-tab" :class="{ active: roomFilter === 'direct' }" @click="roomFilter = 'direct'">{{ getLang('filter_direct') }}</button>
<button type="button" class="comm-tab" :class="{ active: roomFilter === 'group' }" @click="roomFilter = 'group'">{{ getLang('filter_group') }}</button>
</div>
</div>
<div id="room-list">
<div v-if="!rooms.length" class="comm-empty">
<i class="fa fa-comments-o"></i>
<p>{{ getLang('no_rooms') }}</p>
</div>
<div
v-for="room in rooms"
:key="room.id"
class="room-item"
:class="{ active: currentRoomId === room.id }"
@click="selectRoom(room)"
>
<div class="room-item-inner">
<div class="comm-avatar comm-avatar-sm">
{{ initials(roomDisplayName(room)) }}
<span v-if="room.type === 'direct' && isOtherOnline(room)" class="comm-online-dot"></span>
</div>
<div class="room-item-body">
<div class="room-item-top">
<span class="room-item-name">{{ roomDisplayName(room) }}</span>
<small class="text-muted">{{ formatTime((room.latest_message || {}).created_at || room.last_message_at) }}</small>
</div>
<div class="room-item-bottom">
<small class="room-item-preview">{{ roomPreview(room) }}</small>
<span v-if="room.unread_count > 0" class="comm-badge">{{ room.unread_count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="chat-main">
<div class="comm-card chat-panel">
<div class="comm-card-header chat-panel-header">
<div style="display:flex;align-items:center;gap:12px;">
<button type="button" class="chat-back-btn" :title="getLang('go_back', 'Back')" @click="closeRoom">
<i class="fa fa-arrow-right"></i>
</button>
<div class="comm-avatar">{{ currentRoom ? initials(roomDisplayName(currentRoom)) : '?' }}</div>
<div>
<h3 class="comm-card-title">{{ currentRoom ? roomDisplayName(currentRoom) : getLang('select_chat') }}</h3>
<small class="text-muted">{{ chatSubtitle }}</small>
</div>
</div>
<div class="chat-header-tools">
<input
v-if="currentRoomId"
v-model="messageSearch"
type="text"
class="comm-input"
:placeholder="getLang('search_messages')"
>
<button
v-if="currentRoom?.type === 'group'"
type="button"
class="comm-btn comm-btn-ghost btn-sm"
@click="openMembersModal"
>
<i class="fa fa-users"></i>
</button>
</div>
</div>
<div class="chat-body">
<div class="chat-typing-line" :class="{ visible: !!typingText }">{{ typingText }}</div>
<div id="chat-box" ref="messageBox">
<div v-if="!messages.length" class="comm-empty">
<i class="fa fa-comment-o"></i>
<p>{{ currentRoomId ? getLang('no_messages') : getLang('select_chat') }}</p>
</div>
<div
v-for="msg in messages"
:key="msg.id || `tmp-${msg.created_at}-${msg.user_id}`"
class="chat-msg"
:class="parseInt(msg.user_id, 10) === parseInt(currentUserId, 10) ? 'mine' : 'other'"
>
<div class="chat-bubble">
<div v-if="showMessageName(msg)" class="chat-bubble-name">
{{ messageLabel(msg) }}
</div>
<div class="chat-bubble-body">{{ msg.body }}</div>
<div class="chat-bubble-time">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
</div>
<div class="chat-composer">
<ChatComposer
ref="composerRef"
v-model="composerText"
:disabled="!currentRoomId"
:sending="sending"
:placeholder="getLang('type_message')"
@submit="onSubmitMessage"
@typing="onComposerTyping"
/>
</div>
</div>
</div>
</div>
<!-- Direct chat modal -->
<div v-if="showDirectModal" class="modal fade in" style="display:block;" @click.self="showDirectModal = false">
<div class="modal-dialog">
<div class="modal-content" style="border-radius:14px;">
<div class="modal-header">
<button type="button" class="close" @click="showDirectModal = false">&times;</button>
<h4 class="modal-title">{{ getLang('new_direct_chat') }}</h4>
</div>
<form @submit.prevent="createDirectRoom">
<div class="modal-body">
<select v-model="directUserId" class="form-control" required>
<option value="">{{ getLang('select_colleague') }}</option>
<option v-for="u in colleagues" :key="u.id" :value="u.id">
{{ colleagueLabel(u) }}
</option>
</select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" @click="showDirectModal = false">{{ getLang('cancel', 'Cancel') }}</button>
<button type="submit" class="comm-btn comm-btn-primary">{{ getLang('start_chat') }}</button>
</div>
</form>
</div>
</div>
</div>
<!-- Group modal -->
<div v-if="showGroupModal" class="modal fade in" style="display:block;" @click.self="showGroupModal = false">
<div class="modal-dialog">
<div class="modal-content" style="border-radius:14px;">
<div class="modal-header">
<button type="button" class="close" @click="showGroupModal = false">&times;</button>
<h4 class="modal-title">{{ getLang('new_group') }}</h4>
</div>
<form @submit.prevent="createGroupRoom">
<div class="modal-body">
<div class="form-group">
<label>{{ getLang('group_name') }}</label>
<input v-model="groupName" type="text" class="comm-input" required>
</div>
<div class="form-group">
<label>{{ getLang('group_members') }}</label>
<select v-model="groupMemberIds" class="form-control" multiple required>
<option v-for="u in colleagues" :key="u.id" :value="u.id">{{ colleagueLabel(u).replace(' ●', '') }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" @click="showGroupModal = false">{{ getLang('cancel', 'Cancel') }}</button>
<button type="submit" class="comm-btn comm-btn-primary">{{ getLang('create_group') }}</button>
</div>
</form>
</div>
</div>
</div>
<!-- Members modal -->
<div v-if="showMembersModal" class="modal fade in" style="display:block;" @click.self="showMembersModal = false">
<div class="modal-dialog">
<div class="modal-content" style="border-radius:14px;">
<div class="modal-header">
<button type="button" class="close" @click="showMembersModal = false">&times;</button>
<h4 class="modal-title">{{ getLang('group_members') }}</h4>
</div>
<div class="modal-body">
<ul class="list-group">
<li
v-for="m in (currentRoom?.members || [])"
:key="m.id"
class="list-group-item"
style="display:flex;justify-content:space-between;align-items:center;"
>
<span>
{{ fullName(m) }}
<span v-if="m.is_online" class="comm-chip comm-chip-active">{{ getLang('online') }}</span>
</span>
<button
v-if="canManageMembers && m.id !== currentUserId"
type="button"
class="comm-btn comm-btn-danger btn-xs"
@click="removeMember(m.id)"
>{{ getLang('remove') }}</button>
<button
v-else-if="m.id === currentUserId && currentRoom?.type === 'group'"
type="button"
class="comm-btn comm-btn-danger btn-xs"
@click="removeMember(m.id)"
>{{ getLang('leave_group') }}</button>
</li>
</ul>
<hr v-if="canManageMembers">
<div v-if="canManageMembers">
<label>{{ getLang('add_member') }}</label>
<div class="input-group">
<select v-model="addMemberUserId" class="form-control">
<option value="">-</option>
<option v-for="u in availableColleaguesForAdd" :key="u.id" :value="u.id">{{ fullName(u) }}</option>
</select>
<span class="input-group-btn">
<button type="button" class="comm-btn comm-btn-primary" @click="addMember">{{ getLang('add') }}</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showDirectModal || showGroupModal || showMembersModal" class="modal-backdrop fade in"></div>
</div>
</template>
<style scoped>
.chat-shell {
flex: 1 1 auto;
min-height: 0;
}
</style>