631 lines
25 KiB
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">×</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">×</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">×</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>
|