Files
CRM_Montzar/apps/web/src/views/ConversationsView.vue
T

4058 lines
113 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ActivityDto,
ActivityType,
ConversationAccessDto,
ConversationPeerDto,
ConversationPinnedMessageDto,
ConversationRoomDto,
ConversationRoomMemberDto,
CreateActivityRequest,
} from '@mento/shared';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
import {
classifyChatFile,
onMessagePinEvent,
onRoomLiveEvent,
previewLabelForMessage,
useOrgChat,
type ChatDelivery,
2026-09-29 17:22:34 +03:30
type ChatMessage,
type MessagePinEvent,
type RoomLiveEvent,
} from '@/lib/useOrgChat';
import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
2026-09-29 17:22:34 +03:30
type PendingAttach = {
id: string;
file: File;
kind: 'image' | 'voice' | 'video' | 'file';
previewUrl: string;
name: string;
};
const router = useRouter();
const auth = useAuthStore();
const access = ref<ConversationAccessDto | null>(null);
const rooms = ref<ConversationRoomDto[]>([]);
const peers = ref<ConversationPeerDto[]>([]);
const loading = ref(true);
const error = ref('');
const activeRoom = ref<ConversationRoomDto | null>(null);
const draft = ref('');
const showNewDm = ref(false);
const showNewGroup = ref(false);
2026-09-30 11:08:33 +03:30
const showNewChannel = ref(false);
const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
2026-09-30 11:08:33 +03:30
const channelName = ref('');
const channelMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const peerPickerQuery = ref('');
const chatUserId = ref('');
2026-09-29 17:22:34 +03:30
const pendingAttaches = ref<PendingAttach[]>([]);
const sending = ref(false);
const recording = ref(false);
const recordSecs = ref(0);
const lightboxUrl = ref('');
const fileInputEl = ref<HTMLInputElement | null>(null);
const draftInputEl = ref<HTMLTextAreaElement | null>(null);
2026-09-29 21:06:04 +03:30
const emojiOpen = ref(false);
const editingMessageId = ref<string | null>(null);
const replyTo = ref<ChatMessage | null>(null);
const msgMenu = ref<{ id: string; x: number; y: number; sheet: boolean } | null>(null);
const msgMenuEl = ref<HTMLElement | null>(null);
2026-09-29 21:06:04 +03:30
const CHAT_EMOJIS = [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
];
const {
messages,
loadingMessages,
hasMoreMessages,
loadingOlderMessages,
status,
loadMessages,
loadOlderMessages,
sendTyping,
typingMap,
sendText,
2026-09-29 17:22:34 +03:30
sendFile,
2026-09-29 21:06:04 +03:30
editMessage,
deleteMessage,
2026-09-29 21:26:07 +03:30
deleteMessages,
forwardMessages,
pinChatMessage,
unpinChatMessage,
sendRead,
ensureConnected,
setConversationsPageOpen,
refreshUnreadTotal,
clearActiveRoom,
} = useOrgChat();
2026-09-29 11:56:32 +03:30
const msgStreamEl = ref<HTMLElement | null>(null);
let stopRoomLive: (() => boolean) | undefined;
let stopPinLive: (() => boolean) | undefined;
2026-09-29 17:22:34 +03:30
let mediaRecorder: MediaRecorder | null = null;
let recordChunks: Blob[] = [];
let recordTimer: ReturnType<typeof setInterval> | null = null;
let recordStream: MediaStream | null = null;
2026-09-29 21:26:07 +03:30
const selectMode = ref(false);
const selectedIds = ref<string[]>([]);
const showForward = ref(false);
const forwardBusy = ref(false);
const roomListQuery = ref('');
2026-09-30 11:08:33 +03:30
const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all');
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
const chatSearchOpen = ref(false);
const chatSearchQuery = ref('');
const chatSearchIndex = ref(0);
const mentionState = ref<{ start: number; end: number; query: string } | null>(null);
const mentionIndex = ref(0);
const showActivityFromMsg = ref(false);
const activityFromMsg = ref<ChatMessage | null>(null);
const activityTitle = ref('');
const activityType = ref<ActivityType>('task');
const activityBusy = ref(false);
const activityError = ref('');
const activityCreatedId = ref('');
const showMembers = ref(false);
const roomMembers = ref<ConversationRoomMemberDto[]>([]);
const membersBusy = ref(false);
const membersError = ref('');
const memberAddId = ref('');
const typingTick = ref(0);
const ACTIVITY_TYPE_OPTS: Array<{ value: ActivityType; label: string }> = [
{ value: 'task', label: 'وظیفه' },
{ value: 'call', label: 'تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'note', label: 'یادداشت' },
];
2026-09-29 21:26:07 +03:30
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
2026-09-30 11:08:33 +03:30
const canCreateChannel = computed(
() => access.value?.canCreateChannel ?? access.value?.canCreateGroup ?? false,
);
const canPostInActive = computed(() => {
const room = activeRoom.value;
if (!room) return false;
if (room.kind !== 'channel') return true;
return room.canPost !== false;
});
const filteredPeers = computed(() => {
const q = peerPickerQuery.value.trim().toLowerCase();
if (!q) return peers.value;
return peers.value.filter((p) => p.fullName.toLowerCase().includes(q));
});
const selectedGroupPeers = computed(() =>
peers.value.filter((p) => groupMemberIds.value.includes(p.id)),
);
const selectedChannelPeers = computed(() =>
peers.value.filter((p) => channelMemberIds.value.includes(p.id)),
);
const typingLabel = computed(() => {
void typingTick.value;
const roomId = activeRoom.value?.externalRoomId;
if (!roomId) return '';
const list = (typingMap.value?.[roomId] || []).filter(
2026-09-30 12:43:19 +03:30
(t) => Date.now() - t.at < 4000,
);
if (!list.length) return '';
const names = list
.map((t) => {
2026-09-30 12:43:19 +03:30
if (t.name) return t.name;
if (t.userId === '__someone__') return '';
2026-09-30 12:43:19 +03:30
return (
peers.value.find((p) => p.chatUserId === t.userId)?.fullName ||
peers.value.find((p) => p.id === t.userId)?.fullName ||
''
);
})
.filter(Boolean);
if (!names.length) return 'در حال نوشتن…';
if (names.length === 1) return `${names[0]} در حال نوشتن…`;
if (names.length === 2) return `${names[0]} و ${names[1]} در حال نوشتن…`;
return `${toFaDigits(names.length)} نفر در حال نوشتن…`;
});
const canManageActiveMembers = computed(() => {
const room = activeRoom.value;
if (!room || room.kind === 'dm') return false;
// اگر فلگ از API نیامده ولی کاربر سازنده/ادمین سازمان است، مدیریت را نشان بده
if (room.canManageMembers === true) return true;
if (room.canManageMembers === false) return false;
return room.kind === 'group' || room.kind === 'channel';
});
const membersAddCandidates = computed(() => {
const taken = new Set(roomMembers.value.map((m) => m.userId));
return peers.value.filter((p) => !taken.has(p.id));
});
2026-09-29 11:56:32 +03:30
async function scrollToBottom() {
await nextTick();
const el = msgStreamEl.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
const sortedRooms = computed(() =>
[...rooms.value].sort((a, b) => {
const pinA = a.pinned ? 1 : 0;
const pinB = b.pinned ? 1 : 0;
if (pinA !== pinB) return pinB - pinA;
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
return tb - ta;
}),
);
const filteredRooms = computed(() => {
const q = roomListQuery.value.trim().toLowerCase();
return sortedRooms.value.filter((room) => {
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
2026-09-30 11:08:33 +03:30
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
if (!q) return true;
const title = roomTitle(room).toLowerCase();
const preview = (room.lastMessagePreview || '').toLowerCase();
return title.includes(q) || preview.includes(q);
});
});
const mentionCandidates = computed(() => {
const state = mentionState.value;
if (!state) return [] as ConversationPeerDto[];
const q = state.query.trim().toLowerCase();
return peers.value
.filter((p) => !q || p.fullName.toLowerCase().includes(q))
.slice(0, 8);
});
const chatSearchHits = computed(() => {
const q = chatSearchQuery.value.trim().toLowerCase();
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
return messages.value.filter((m) => {
if (m.deleted) return false;
if (m.type === 'text') return (m.content || '').toLowerCase().includes(q);
return previewLabelForMessage(m).toLowerCase().includes(q);
});
});
const chatSearchHitCount = computed(() => chatSearchHits.value.length);
const activeSearchHitId = computed(() => {
const hits = chatSearchHits.value;
if (!hits.length) return '';
const idx = ((chatSearchIndex.value % hits.length) + hits.length) % hits.length;
return hits[idx]?.id ?? '';
});
function roomTitle(room: ConversationRoomDto) {
2026-09-30 11:08:33 +03:30
if (room.kind === 'group' || room.kind === 'channel') return room.name;
const peer = peers.value.find((p) => p.id === room.peerUserId);
return peer?.fullName ?? room.name;
}
2026-09-30 11:08:33 +03:30
function roomKindLabel(kind: ConversationRoomDto['kind']) {
if (kind === 'channel') return 'کانال';
if (kind === 'group') return 'گروه';
return 'خصوصی';
}
type TextSeg = { text: string; mention?: boolean; hit?: boolean };
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
const raw = String(content || '');
if (!raw) return [];
const names = [...peers.value]
.map((p) => p.fullName.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
const mentionRe = names.length
? new RegExp(`(@(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')}))`, 'g')
: /(@[^\s@]{2,40})/g;
const parts: TextSeg[] = [];
let last = 0;
for (const match of raw.matchAll(mentionRe)) {
const idx = match.index ?? 0;
if (idx > last) parts.push({ text: raw.slice(last, idx) });
parts.push({ text: match[0], mention: true });
last = idx + match[0].length;
}
if (last < raw.length) parts.push({ text: raw.slice(last) });
const q = highlightQuery.trim();
if (!q) return parts.length ? parts : [{ text: raw }];
const out: TextSeg[] = [];
const qLower = q.toLowerCase();
for (const part of parts) {
if (part.mention) {
out.push(part);
continue;
}
const text = part.text;
const lower = text.toLowerCase();
let cursor = 0;
let found = lower.indexOf(qLower, cursor);
if (found < 0) {
out.push(part);
continue;
}
while (found >= 0) {
if (found > cursor) out.push({ text: text.slice(cursor, found) });
out.push({ text: text.slice(found, found + q.length), hit: true });
cursor = found + q.length;
found = lower.indexOf(qLower, cursor);
}
if (cursor < text.length) out.push({ text: text.slice(cursor) });
}
return out.length ? out : [{ text: raw }];
}
function formatListTime(iso: string | null) {
if (!iso) return '';
try {
return formatClockFa(new Date(iso));
} catch {
return '';
}
}
function deliveryLabel(delivery?: ChatDelivery) {
if (delivery === 'read') return '✓✓';
if (delivery === 'sent') return '✓';
return '…';
}
function roomPreviewText(room: ConversationRoomDto) {
const raw = (room.lastMessagePreview || '').trim();
if (!raw) return 'بدون پیام';
if (raw === 'وویس') return 'پیام صوتی';
return raw;
}
function closeMobileChat() {
clearActiveRoom();
activeRoom.value = null;
clearPendingAttaches();
2026-09-29 21:06:04 +03:30
cancelEdit();
cancelReply();
2026-09-29 21:26:07 +03:30
exitSelectMode();
2026-09-29 21:06:04 +03:30
emojiOpen.value = false;
msgMenu.value = null;
2026-09-29 21:26:07 +03:30
showForward.value = false;
closeChatSearch();
mentionState.value = null;
pinnedMessage.value = null;
closeActivityFromMsg();
if (recording.value) mediaRecorder?.stop();
}
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
2026-09-29 21:06:04 +03:30
const canSend = computed(
() => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value),
);
const showMicInsteadOfSend = computed(
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
);
2026-09-29 21:26:07 +03:30
const selectedCount = computed(() => selectedIds.value.length);
const forwardTargets = computed(() =>
sortedRooms.value.filter((r) => r.externalRoomId !== activeRoom.value?.externalRoomId),
);
function isSelected(id: string) {
return selectedIds.value.includes(id);
}
function exitSelectMode() {
selectMode.value = false;
selectedIds.value = [];
}
function enterSelectMode(msg?: ChatMessage) {
selectMode.value = true;
msgMenu.value = null;
emojiOpen.value = false;
cancelEdit();
if (msg && !msg.deleted) selectedIds.value = [msg.id];
}
function toggleSelect(msg: ChatMessage) {
if (msg.deleted || msg.optimistic) return;
if (!selectMode.value) {
enterSelectMode(msg);
return;
}
if (isSelected(msg.id)) {
selectedIds.value = selectedIds.value.filter((id) => id !== msg.id);
if (!selectedIds.value.length) exitSelectMode();
} else {
selectedIds.value = [...selectedIds.value, msg.id];
}
}
function selectedMessages(): ChatMessage[] {
const set = new Set(selectedIds.value);
return messages.value.filter((m) => set.has(m.id) && !m.deleted);
}
async function deleteSelected() {
if (!activeRoom.value || !selectedIds.value.length) return;
const list = selectedMessages();
if (!list.length) return;
const mineCount = list.filter((m) => isMineMessage(m)).length;
const otherCount = list.length - mineCount;
const parts: string[] = [];
if (mineCount) parts.push(`${mineCount} پیام خودتان برای همه`);
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
2026-09-29 21:26:07 +03:30
try {
const pinnedId = pinnedMessage.value?.messageId;
await deleteMessages(
activeRoom.value.externalRoomId,
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
);
if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null;
2026-09-29 21:26:07 +03:30
exitSelectMode();
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
}
}
function openForwardPicker(msgs?: ChatMessage[]) {
msgMenu.value = null;
if (msgs?.length) {
selectedIds.value = msgs.map((m) => m.id);
selectMode.value = true;
}
if (!selectedIds.value.length) return;
showForward.value = true;
}
async function confirmForward(targetRoomId: string) {
if (!targetRoomId || forwardBusy.value) return;
const list = selectedMessages();
if (!list.length) return;
forwardBusy.value = true;
error.value = '';
try {
await forwardMessages(targetRoomId, list);
showForward.value = false;
exitSelectMode();
} catch (e) {
error.value = e instanceof Error ? e.message : 'ارسال برای دیگری ناموفق بود.';
} finally {
forwardBusy.value = false;
}
}
2026-09-29 21:06:04 +03:30
function insertEmoji(mark: string) {
const el = draftInputEl.value;
const cur = draft.value;
if (el && typeof el.selectionStart === 'number') {
const start = el.selectionStart;
const end = el.selectionEnd ?? start;
draft.value = cur.slice(0, start) + mark + cur.slice(end);
nextTick(() => {
el.focus();
const pos = start + mark.length;
el.setSelectionRange(pos, pos);
});
} else {
draft.value = cur + mark;
}
}
function cancelEdit() {
editingMessageId.value = null;
}
function cancelReply() {
replyTo.value = null;
}
function startReplyMessage(msg: ChatMessage) {
if (msg.deleted || msg.optimistic) return;
replyTo.value = msg;
editingMessageId.value = null;
emojiOpen.value = false;
msgMenu.value = null;
nextTick(() => draftInputEl.value?.focus());
}
2026-09-29 21:06:04 +03:30
function startEditMessage(msg: ChatMessage) {
if (msg.deleted || msg.type !== 'text' || msg.optimistic) return;
editingMessageId.value = msg.id;
replyTo.value = null;
2026-09-29 21:06:04 +03:30
draft.value = msg.content;
emojiOpen.value = false;
msgMenu.value = null;
clearPendingAttaches();
nextTick(() => draftInputEl.value?.focus());
}
function replyPreviewText(replyId: string | null | undefined) {
if (!replyId) return 'پیام';
const m = messages.value.find((item) => item.id === replyId);
if (!m) return 'پیام';
if (m.type === 'text') {
const t = (m.content || '').trim();
return t.length > 90 ? `${t.slice(0, 90)}…` : t || 'پیام';
}
return previewLabelForMessage(m);
}
function scrollToMessage(id: string | null | undefined) {
if (!id || !msgStreamEl.value) return;
const row = msgStreamEl.value.querySelector(`[data-msg-id="${CSS.escape(id)}"]`);
if (!(row instanceof HTMLElement)) return;
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('flash');
window.setTimeout(() => row.classList.remove('flash'), 1200);
}
async function refreshPinnedMessage(roomId: string) {
if (!roomId) {
pinnedMessage.value = null;
return;
}
try {
const { data } = await api.get<ConversationPinnedMessageDto | null>(
'/conversations/messages/pin',
{ params: { externalRoomId: roomId } },
);
pinnedMessage.value = data?.messageId ? data : null;
} catch {
pinnedMessage.value = null;
}
}
function applyMessagePin(event: MessagePinEvent) {
if (activeRoom.value?.externalRoomId !== event.roomId) return;
if (!event.messageId) {
pinnedMessage.value = null;
return;
}
pinnedMessage.value = {
messageId: event.messageId,
preview: event.preview || pinnedMessage.value?.preview || 'پیام سنجاق‌شده',
pinnedByUserId: pinnedMessage.value?.pinnedByUserId || '',
createdAt: new Date().toISOString(),
};
}
async function togglePinMessage(msg: ChatMessage) {
msgMenu.value = null;
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
const roomId = activeRoom.value.externalRoomId;
try {
if (pinnedMessage.value?.messageId === msg.id) {
await unpinChatMessage(roomId);
pinnedMessage.value = null;
} else {
await pinChatMessage(roomId, msg);
pinnedMessage.value = {
messageId: msg.id,
preview: previewLabelForMessage(msg).slice(0, 240),
pinnedByUserId: '',
createdAt: new Date().toISOString(),
};
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'سنجاق پیام ناموفق بود.';
}
}
async function clearPinnedMessage() {
if (!activeRoom.value) return;
try {
await unpinChatMessage(activeRoom.value.externalRoomId);
pinnedMessage.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : 'برداشتن سنجاق ناموفق بود.';
}
}
async function toggleRoomPinned() {
if (!activeRoom.value) return;
const room = activeRoom.value;
const next = !room.pinned;
try {
await api.post('/conversations/rooms/pin', {
externalRoomId: room.externalRoomId,
pinned: next,
});
const updated = { ...room, pinned: next };
activeRoom.value = updated;
rooms.value = rooms.value.map((r) =>
r.externalRoomId === room.externalRoomId ? { ...r, pinned: next } : r,
);
} catch (e) {
error.value = e instanceof Error ? e.message : 'سنجاق گفتگو ناموفق بود.';
}
}
function openChatSearch() {
chatSearchOpen.value = true;
chatSearchQuery.value = '';
chatSearchIndex.value = 0;
emojiOpen.value = false;
mentionState.value = null;
msgMenu.value = null;
}
function closeChatSearch() {
chatSearchOpen.value = false;
chatSearchQuery.value = '';
chatSearchIndex.value = 0;
}
function goSearchHit(delta: number) {
const total = chatSearchHitCount.value;
if (!total) return;
chatSearchIndex.value = (chatSearchIndex.value + delta + total) % total;
nextTick(() => scrollToMessage(activeSearchHitId.value));
}
function updateMentionState() {
const el = draftInputEl.value;
if (!el) {
mentionState.value = null;
return;
}
const caret = el.selectionStart ?? draft.value.length;
const before = draft.value.slice(0, caret);
const match = before.match(/(^|[\s\n])@([^\s@]*)$/);
if (!match) {
mentionState.value = null;
return;
}
const query = match[2] ?? '';
const start = caret - query.length - 1;
mentionState.value = { start, end: caret, query };
mentionIndex.value = 0;
}
function insertMention(peer: ConversationPeerDto) {
const state = mentionState.value;
if (!state) return;
const token = `@${peer.fullName} `;
draft.value = draft.value.slice(0, state.start) + token + draft.value.slice(state.end);
mentionState.value = null;
nextTick(() => {
const el = draftInputEl.value;
if (!el) return;
const pos = state.start + token.length;
el.focus();
el.setSelectionRange(pos, pos);
});
}
function onDraftInput() {
updateMentionState();
if (activeRoom.value && draft.value.trim()) {
void sendTyping(activeRoom.value.externalRoomId);
}
}
async function onMsgStreamScroll() {
const el = msgStreamEl.value;
if (!el || !activeRoom.value) return;
if (el.scrollTop > 80) return;
if (!hasMoreMessages.value || loadingOlderMessages.value) return;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
const loaded = await loadOlderMessages(activeRoom.value.externalRoomId);
if (!loaded) return;
await nextTick();
el.scrollTop = el.scrollHeight - prevHeight + prevTop;
}
async function openMembersPanel() {
if (!activeRoom.value || activeRoom.value.kind === 'dm') return;
showMembers.value = true;
membersError.value = '';
memberAddId.value = '';
await refreshRoomMembers();
}
async function refreshRoomMembers() {
if (!activeRoom.value) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.get<ConversationRoomMemberDto[] | { data?: ConversationRoomMemberDto[] }>(
'/conversations/rooms/members',
{ params: { externalRoomId: activeRoom.value.externalRoomId } },
);
let list = Array.isArray(data)
? data
: Array.isArray((data as { data?: ConversationRoomMemberDto[] })?.data)
? (data as { data: ConversationRoomMemberDto[] }).data
: [];
// بازیابی اعضای فعال از روی فرستنده‌های پیام (اتاق‌های قدیمی بدون ردیف CRM)
const taken = new Set(list.map((m) => m.userId));
const senderChatIds = new Set(
messages.value
.filter((m) => m.roomId === activeRoom.value?.externalRoomId)
.map((m) => m.senderId)
.filter(Boolean),
);
const inferredPeers = peers.value.filter(
(p) => senderChatIds.has(p.chatUserId) && !taken.has(p.id),
);
if (inferredPeers.length && canManageActiveMembers.value) {
for (const p of inferredPeers.slice(0, 30)) {
try {
const { data: synced } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members',
{
externalRoomId: activeRoom.value.externalRoomId,
userId: p.id,
role: 'member',
},
);
if (Array.isArray(synced)) list = synced;
} catch {
// best-effort
}
}
} else if (inferredPeers.length) {
list = [
...list,
...inferredPeers.map((p) => ({
userId: p.id,
fullName: p.fullName,
role: 'member' as const,
hasAvatar: Boolean(p.hasAvatar),
isMe: false,
})),
];
}
roomMembers.value = list;
if (!list.length) {
membersError.value = 'عضوی برای این گفتگو ثبت نشده. با «افزودن» اعضا را اضافه کنید.';
}
} catch (e) {
roomMembers.value = [];
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg)
? msg.join('، ')
: msg ||
(e.response?.status === 404
? 'گفتگو در CRM پیدا نشد یا جدول اعضا migrate نشده است.'
: 'بارگذاری اعضا ناموفق بود.');
} else {
membersError.value = e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function addRoomMember() {
if (!activeRoom.value || !memberAddId.value) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>('/conversations/rooms/members', {
externalRoomId: activeRoom.value.externalRoomId,
userId: memberAddId.value,
role: 'member',
});
roomMembers.value = Array.isArray(data) ? data : [];
memberAddId.value = '';
// refresh canPost/canManage for current user list
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'افزودن عضو ناموفق بود.';
} else {
membersError.value = 'افزودن عضو ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function removeRoomMember(userId: string) {
if (!activeRoom.value || !canManageActiveMembers.value) return;
if (!window.confirm('این عضو از گفتگو حذف شود؟')) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members/remove',
{
externalRoomId: activeRoom.value.externalRoomId,
userId,
},
);
roomMembers.value = Array.isArray(data) ? data : [];
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'حذف عضو ناموفق بود.';
} else {
membersError.value = 'حذف عضو ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function toggleMemberAdmin(member: ConversationRoomMemberDto) {
if (!activeRoom.value || !canManageActiveMembers.value) return;
const nextRole = member.role === 'admin' ? 'member' : 'admin';
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members/role',
{
externalRoomId: activeRoom.value.externalRoomId,
userId: member.userId,
role: nextRole,
},
);
roomMembers.value = Array.isArray(data) ? data : [];
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'تغییر نقش ناموفق بود.';
} else {
membersError.value = 'تغییر نقش ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
2026-09-29 21:06:04 +03:30
async function confirmDeleteMessage(msg: ChatMessage) {
msgMenu.value = null;
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
const mine = isMineMessage(msg);
const ok = window.confirm(
mine
? 'این پیام برای همه حذف شود؟'
: 'این پیام فقط برای شما حذف شود؟ (برای طرف مقابل باقی می‌ماند)',
);
if (!ok) return;
2026-09-29 21:06:04 +03:30
try {
await deleteMessage(activeRoom.value.externalRoomId, msg.id, {
forEveryone: mine,
});
2026-09-29 21:06:04 +03:30
if (editingMessageId.value === msg.id) cancelEdit();
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
2026-09-29 21:06:04 +03:30
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
}
}
2026-09-30 10:04:54 +03:30
function isMobileComposer() {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches;
}
function insertComposerNewline() {
const el = draftInputEl.value;
if (!el) {
draft.value += '\n';
return;
}
const start = el.selectionStart ?? draft.value.length;
const end = el.selectionEnd ?? start;
draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
nextTick(() => {
const pos = start + 1;
el.focus();
el.setSelectionRange(pos, pos);
});
}
function onComposerKeydown(event: KeyboardEvent) {
if (mentionState.value && mentionCandidates.value.length) {
if (event.key === 'ArrowDown') {
event.preventDefault();
mentionIndex.value = (mentionIndex.value + 1) % mentionCandidates.value.length;
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
mentionIndex.value =
(mentionIndex.value - 1 + mentionCandidates.value.length) % mentionCandidates.value.length;
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
const peer = mentionCandidates.value[mentionIndex.value];
if (peer) insertMention(peer);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
mentionState.value = null;
return;
}
}
if (event.key !== 'Enter') return;
2026-09-30 10:04:54 +03:30
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
if (isMobileComposer()) return;
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
2026-09-30 10:04:54 +03:30
insertComposerNewline();
return;
}
if (event.shiftKey) return;
event.preventDefault();
void submitMessage();
}
2026-09-29 21:06:04 +03:30
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
2026-09-29 21:26:07 +03:30
if (msg.deleted || msg.optimistic) return;
if (selectMode.value) {
toggleSelect(msg);
return;
}
2026-09-29 21:06:04 +03:30
event.preventDefault();
let x = 0;
let y = 0;
if ('changedTouches' in event && event.changedTouches[0]) {
x = event.changedTouches[0].clientX;
y = event.changedTouches[0].clientY;
} else if ('touches' in event && event.touches[0]) {
x = event.touches[0].clientX;
y = event.touches[0].clientY;
} else {
x = (event as MouseEvent).clientX;
y = (event as MouseEvent).clientY;
}
const sheet = window.matchMedia('(max-width: 900px)').matches;
msgMenu.value = { id: msg.id, x, y, sheet };
if (sheet) return;
nextTick(() => {
const el = msgMenuEl.value;
if (!el || !msgMenu.value) return;
const rect = el.getBoundingClientRect();
const pad = 12;
let left = x - rect.width / 2;
let top = y - rect.height - 10;
if (top < pad) top = y + 10;
if (top + rect.height > window.innerHeight - pad) {
top = Math.max(pad, window.innerHeight - pad - rect.height);
}
left = Math.min(Math.max(left, pad), window.innerWidth - pad - rect.width);
msgMenu.value = { ...msgMenu.value, x: left, y: top };
});
2026-09-29 21:06:04 +03:30
}
function menuMessage(): ChatMessage | null {
if (!msgMenu.value) return null;
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
}
2026-09-29 21:26:07 +03:30
function isMineMessage(msg: ChatMessage | null | undefined) {
if (!msg) return false;
return msg.senderId === chatUserId.value || Boolean(msg.optimistic);
}
2026-09-29 21:06:04 +03:30
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
function clearLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function onBubbleTouchStart(msg: ChatMessage, event: TouchEvent) {
clearLongPress();
longPressTimer = setTimeout(() => {
longPressTimer = null;
openMsgMenu(msg, event);
}, 480);
}
function onBubbleTouchEnd() {
clearLongPress();
}
2026-09-29 17:22:34 +03:30
function classifyPending(file: File): PendingAttach['kind'] {
return classifyChatFile(file);
2026-09-29 17:22:34 +03:30
}
function clearPendingAttaches() {
for (const item of pendingAttaches.value) {
if (item.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
}
pendingAttaches.value = [];
}
function removePending(id: string) {
const item = pendingAttaches.value.find((a) => a.id === id);
if (item?.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
pendingAttaches.value = pendingAttaches.value.filter((a) => a.id !== id);
}
function addFiles(files: FileList | File[]) {
const list = Array.from(files);
for (const file of list) {
pendingAttaches.value = [
...pendingAttaches.value,
{
id: crypto.randomUUID(),
file,
kind: classifyPending(file),
previewUrl: URL.createObjectURL(file),
name: file.name,
},
];
}
}
function onFilePicked(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files?.length) addFiles(input.files);
input.value = '';
}
function mediaSrc(msg: ChatMessage) {
return msg.localPreviewUrl || msg.mediaUrl || '';
}
function stopRecordTimer() {
if (recordTimer) {
clearInterval(recordTimer);
recordTimer = null;
}
}
function stopMicTracks() {
recordStream?.getTracks().forEach((t) => t.stop());
recordStream = null;
}
async function toggleVoiceRecord() {
if (recording.value) {
mediaRecorder?.stop();
return;
}
try {
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordChunks = [];
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: '';
mediaRecorder = mime
? new MediaRecorder(recordStream, { mimeType: mime })
: new MediaRecorder(recordStream);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size) recordChunks.push(e.data);
};
mediaRecorder.onstop = () => {
stopRecordTimer();
recording.value = false;
const type = mediaRecorder?.mimeType || 'audio/webm';
stopMicTracks();
mediaRecorder = null;
if (!recordChunks.length) return;
const blob = new Blob(recordChunks, { type });
recordChunks = [];
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm';
// Force audio/* so classify never treats MediaRecorder output as video
const audioMime = type.startsWith('audio/')
? type.split(';')[0]
: ext === 'ogg'
? 'audio/ogg'
: ext === 'm4a'
? 'audio/mp4'
: 'audio/webm';
const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: audioMime });
2026-09-29 17:22:34 +03:30
addFiles([file]);
recordSecs.value = 0;
};
mediaRecorder.start();
recording.value = true;
recordSecs.value = 0;
recordTimer = setInterval(() => {
recordSecs.value += 1;
}, 1000);
} catch {
error.value = 'دسترسی به میکروفون ممکن نشد.';
stopMicTracks();
recording.value = false;
}
}
function recordLabel() {
const m = Math.floor(recordSecs.value / 60);
const s = recordSecs.value % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function applyRoomLive(event: RoomLiveEvent) {
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
if (idx < 0) {
void refreshRoomsSoft();
return;
}
const room = rooms.value[idx];
const isActive = activeRoom.value?.externalRoomId === event.roomId;
const next: ConversationRoomDto = {
...room,
lastMessagePreview: event.preview,
lastMessageAt: event.at,
unreadCount: event.fromSelf || isActive ? 0 : (room.unreadCount || 0) + 1,
};
const nextRooms = [...rooms.value];
nextRooms[idx] = next;
rooms.value = nextRooms;
if (isActive && activeRoom.value) {
activeRoom.value = next;
}
}
async function refreshRoomsSoft() {
try {
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
rooms.value = data;
if (activeRoom.value) {
const next = data.find((r) => r.externalRoomId === activeRoom.value!.externalRoomId);
if (next) activeRoom.value = { ...next, unreadCount: 0 };
}
await refreshUnreadTotal();
} catch {
// keep previous
}
}
async function bootstrap() {
loading.value = true;
error.value = '';
try {
2026-09-29 15:27:14 +03:30
const accessRes = await api.get<ConversationAccessDto>('/conversations/access');
access.value = accessRes.data;
if (!accessRes.data.canUse) {
2026-09-29 15:27:14 +03:30
error.value = 'به گفتگو دسترسی ندارید.';
return;
}
2026-09-29 15:27:14 +03:30
const [roomsOutcome, peersOutcome, sessionOutcome] = await Promise.allSettled([
api.get<ConversationRoomDto[]>('/conversations/rooms'),
api.get<ConversationPeerDto[]>('/conversations/peers'),
api.get<{ chatUserId: string }>('/conversations/session'),
]);
if (peersOutcome.status === 'fulfilled') {
peers.value = peersOutcome.value.data;
}
if (sessionOutcome.status === 'fulfilled') {
chatUserId.value = sessionOutcome.value.data.chatUserId;
}
if (roomsOutcome.status === 'fulfilled') {
rooms.value = roomsOutcome.value.data;
await refreshUnreadTotal();
2026-09-29 15:27:14 +03:30
} else {
const e = roomsOutcome.reason;
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: msg ||
(e.response?.status === 503 || e.response?.status === 502
? 'اتصال به سرویس چت برقرار نشد. Host و API Key را در سوپرادمین بررسی کنید.'
: 'بارگذاری لیست گفتگو ناموفق بود.');
} else {
error.value = 'بارگذاری لیست گفتگو ناموفق بود.';
}
}
await ensureConnected();
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
2026-09-29 15:27:14 +03:30
} else if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: msg || 'بارگذاری گفتگو ناموفق بود.';
} else {
error.value = 'بارگذاری گفتگو ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function openRoom(room: ConversationRoomDto) {
const cleared = { ...room, unreadCount: 0 };
activeRoom.value = cleared;
rooms.value = rooms.value.map((r) =>
r.externalRoomId === room.externalRoomId ? cleared : r,
);
closeChatSearch();
mentionState.value = null;
await loadMessages(room.externalRoomId);
await refreshPinnedMessage(room.externalRoomId);
sendRead(room.externalRoomId);
void refreshUnreadTotal();
}
async function submitMessage() {
2026-09-29 17:22:34 +03:30
if (!activeRoom.value) return;
2026-09-30 11:08:33 +03:30
if (!canPostInActive.value) {
error.value = 'فقط مدیران کانال می‌توانند پیام بفرستند.';
return;
}
2026-09-29 17:22:34 +03:30
if (sending.value || recording.value) return;
const text = draft.value.trim();
const attaches = [...pendingAttaches.value];
2026-09-29 21:06:04 +03:30
const editingId = editingMessageId.value;
if (editingId) {
if (!text) return;
sending.value = true;
error.value = '';
try {
await editMessage(activeRoom.value.externalRoomId, editingId, text);
draft.value = '';
editingMessageId.value = null;
emojiOpen.value = false;
} catch (e) {
error.value = e instanceof Error ? e.message : 'ویرایش پیام ناموفق بود.';
} finally {
sending.value = false;
}
return;
}
2026-09-29 17:22:34 +03:30
if (!text && !attaches.length) return;
sending.value = true;
error.value = '';
const roomId = activeRoom.value.externalRoomId;
const replyId = replyTo.value?.id ?? null;
2026-09-29 17:22:34 +03:30
draft.value = '';
pendingAttaches.value = [];
2026-09-29 21:06:04 +03:30
emojiOpen.value = false;
replyTo.value = null;
2026-09-29 17:22:34 +03:30
try {
2026-09-29 17:22:34 +03:30
if (text) {
await sendText(roomId, text, replyId);
2026-09-29 17:22:34 +03:30
}
for (const item of attaches) {
try {
await sendFile(roomId, item.file, replyId);
2026-09-29 17:22:34 +03:30
} finally {
if (item.previewUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(item.previewUrl);
} catch {
// ignore
}
}
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'ارسال پیام ناموفق بود.';
} finally {
sending.value = false;
}
}
async function startDm() {
if (!dmPeerId.value) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
peerUserId: dmPeerId.value,
});
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
showNewDm.value = false;
dmPeerId.value = '';
await openRoom(data);
} catch {
error.value = 'شروع گفتگوی جدید ناموفق بود.';
}
}
async function startGroup() {
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
name: groupName.value.trim(),
memberUserIds: groupMemberIds.value,
});
rooms.value = [data, ...rooms.value];
showNewGroup.value = false;
groupName.value = '';
groupMemberIds.value = [];
await openRoom(data);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'اجازه ساخت گروه را ندارید.';
} else {
error.value = 'ساخت گروه ناموفق بود.';
}
}
}
2026-09-30 11:08:33 +03:30
async function startChannel() {
if (!channelName.value.trim() || !channelMemberIds.value.length) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/channel', {
name: channelName.value.trim(),
memberUserIds: channelMemberIds.value,
});
rooms.value = [{ ...data, canPost: true }, ...rooms.value];
showNewChannel.value = false;
channelName.value = '';
channelMemberIds.value = [];
await openRoom({ ...data, canPost: true });
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'اجازه ساخت کانال را ندارید.';
} else {
error.value = 'ساخت کانال ناموفق بود.';
}
}
}
function toggleGroupMember(id: string) {
if (groupMemberIds.value.includes(id)) {
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
} else {
groupMemberIds.value = [...groupMemberIds.value, id];
}
}
2026-09-30 11:08:33 +03:30
function toggleChannelMember(id: string) {
if (channelMemberIds.value.includes(id)) {
channelMemberIds.value = channelMemberIds.value.filter((x) => x !== id);
} else {
channelMemberIds.value = [...channelMemberIds.value, id];
}
}
function openNewDm() {
peerPickerQuery.value = '';
dmPeerId.value = '';
showNewDm.value = true;
}
function openNewGroup() {
peerPickerQuery.value = '';
groupName.value = '';
groupMemberIds.value = [];
showNewGroup.value = true;
}
function openNewChannel() {
peerPickerQuery.value = '';
channelName.value = '';
channelMemberIds.value = [];
showNewChannel.value = true;
}
function messageToActivityTitle(msg: ChatMessage) {
if (msg.type === 'text') {
const t = (msg.content || '').trim().replace(/\s+/g, ' ');
if (!t) return 'پیگیری گفتگو';
return t.length > 80 ? `${t.slice(0, 80)}…` : t;
}
return `پیگیری: ${previewLabelForMessage(msg)}`;
}
function messageToActivityDescription(msg: ChatMessage) {
const roomName = activeRoom.value ? roomTitle(activeRoom.value) : 'گفتگو';
const body =
msg.type === 'text'
? (msg.content || '').trim()
: previewLabelForMessage(msg);
return `ایجادشده از گفتگو «${roomName}»\n\n${body}`.trim();
}
function openConvertToActivity(msg: ChatMessage) {
if (msg.deleted || msg.optimistic) return;
msgMenu.value = null;
activityFromMsg.value = msg;
activityTitle.value = messageToActivityTitle(msg);
activityType.value = 'task';
activityError.value = '';
activityCreatedId.value = '';
showActivityFromMsg.value = true;
}
function closeActivityFromMsg() {
showActivityFromMsg.value = false;
activityFromMsg.value = null;
activityBusy.value = false;
activityError.value = '';
activityCreatedId.value = '';
}
async function submitActivityFromMsg() {
const msg = activityFromMsg.value;
const title = activityTitle.value.trim();
if (!msg || !title) {
activityError.value = 'عنوان فعالیت را وارد کنید.';
return;
}
activityBusy.value = true;
activityError.value = '';
try {
const payload: CreateActivityRequest = {
title,
type: activityType.value,
description: messageToActivityDescription(msg),
assigneeId: auth.user?.id || undefined,
tags: ['گفتگو'],
};
const { data } = await api.post<ActivityDto>('/activities', payload);
activityCreatedId.value = data.id;
} catch (e) {
if (axios.isAxiosError(e)) {
const msgBody = (e.response?.data as { message?: string | string[] })?.message;
activityError.value = Array.isArray(msgBody)
? msgBody.join('، ')
: msgBody || 'ساخت فعالیت ناموفق بود.';
} else {
activityError.value = 'ساخت فعالیت ناموفق بود.';
}
} finally {
activityBusy.value = false;
}
}
async function openCreatedActivity() {
const id = activityCreatedId.value;
closeActivityFromMsg();
if (id) await router.push({ name: 'activity-detail', params: { id } });
}
watch(showNewDm, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(showNewGroup, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(showNewChannel, (v) => {
if (v) peerPickerQuery.value = '';
});
watch(activeRoom, () => {
draft.value = '';
2026-09-29 17:22:34 +03:30
clearPendingAttaches();
2026-09-29 21:26:07 +03:30
exitSelectMode();
cancelEdit();
cancelReply();
2026-09-29 21:26:07 +03:30
emojiOpen.value = false;
msgMenu.value = null;
showForward.value = false;
closeChatSearch();
mentionState.value = null;
2026-09-29 17:22:34 +03:30
if (recording.value) {
mediaRecorder?.stop();
}
});
2026-09-29 11:56:32 +03:30
watch(
() => messages.value.length,
() => {
if (chatSearchOpen.value) return;
2026-09-29 11:56:32 +03:30
void scrollToBottom();
},
);
watch(loadingMessages, (v) => {
if (!v && !chatSearchOpen.value) void scrollToBottom();
});
watch(chatSearchHits, (hits) => {
if (!chatSearchOpen.value) return;
if (!hits.length) {
chatSearchIndex.value = 0;
return;
}
if (chatSearchIndex.value >= hits.length) chatSearchIndex.value = 0;
nextTick(() => scrollToMessage(activeSearchHitId.value));
2026-09-29 11:56:32 +03:30
});
let typingUiTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
setConversationsPageOpen(true);
stopRoomLive = onRoomLiveEvent(applyRoomLive);
stopPinLive = onMessagePinEvent(applyMessagePin);
void bootstrap();
typingUiTimer = setInterval(() => {
typingTick.value += 1;
}, 1000);
});
onUnmounted(() => {
setConversationsPageOpen(false);
stopRoomLive?.();
stopPinLive?.();
if (typingUiTimer) clearInterval(typingUiTimer);
clearActiveRoom();
2026-09-29 17:22:34 +03:30
clearPendingAttaches();
if (recording.value) mediaRecorder?.stop();
stopRecordTimer();
stopMicTracks();
});
</script>
<template>
<div class="conv-page" :class="{ 'chat-open': mobileChatOpen }">
<div class="conv-aurora" aria-hidden="true" />
<header class="conv-head desktop-head">
<div>
<p class="eyebrow">درون سازمان</p>
<h1>گفتگو</h1>
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیام‌رسان بیرونی.</p>
</div>
<div class="head-actions">
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
</div>
</header>
<header class="mobile-list-head" :hidden="mobileChatOpen">
<h1>گفتگو</h1>
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
</header>
<p v-if="error" class="err-banner">{{ error }}</p>
<p v-else-if="loading" class="muted center">در حال آماده‌سازی…</p>
<div v-else class="conv-shell">
<aside class="conv-rail" :hidden="mobileChatOpen">
<div class="rail-actions">
<button type="button" class="fab" @click="openNewDm">+ گفتگو</button>
<button
v-if="canCreateGroup"
type="button"
class="fab ghost"
@click="openNewGroup"
>
گروه
</button>
2026-09-30 11:08:33 +03:30
<button
v-if="canCreateChannel"
type="button"
class="fab ghost"
@click="openNewChannel"
2026-09-30 11:08:33 +03:30
>
کانال
</button>
</div>
<div class="rail-filters">
<label class="rail-search">
<UiIcon name="search" :size="16" />
<input
v-model="roomListQuery"
type="search"
placeholder="جستجوی گفتگو…"
autocomplete="off"
/>
</label>
<div class="rail-tabs" role="tablist" aria-label="نوع گفتگو">
<button
type="button"
role="tab"
:aria-selected="roomListTab === 'all'"
:class="{ active: roomListTab === 'all' }"
@click="roomListTab = 'all'"
>
همه
</button>
<button
type="button"
role="tab"
:aria-selected="roomListTab === 'dm'"
:class="{ active: roomListTab === 'dm' }"
@click="roomListTab = 'dm'"
>
خصوصی
</button>
<button
type="button"
role="tab"
:aria-selected="roomListTab === 'group'"
:class="{ active: roomListTab === 'group' }"
@click="roomListTab = 'group'"
>
گروه
</button>
2026-09-30 11:08:33 +03:30
<button
type="button"
role="tab"
:aria-selected="roomListTab === 'channel'"
:class="{ active: roomListTab === 'channel' }"
@click="roomListTab = 'channel'"
>
کانال
</button>
</div>
</div>
<ul class="room-list">
<li v-if="!filteredRooms.length" class="rail-empty">
{{ roomListQuery.trim() ? 'گفتگویی پیدا نشد' : 'گفتگویی در این بخش نیست' }}
</li>
<li v-for="room in filteredRooms" :key="room.externalRoomId">
<button
type="button"
class="room-chip"
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned }"
@click="openRoom(room)"
>
<span class="avatar-ring">
<UserAvatar :name="roomTitle(room)" :size="40" />
</span>
<span class="room-meta">
<strong>
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
{{ roomTitle(room) }}
</strong>
<small>{{ roomPreviewText(room) }}</small>
</span>
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
<time>{{ formatListTime(room.lastMessageAt) }}</time>
</button>
</li>
</ul>
</aside>
<section
class="conv-stage"
:class="{ empty: !activeRoom, open: mobileChatOpen }"
>
<div v-if="!activeRoom" class="empty-stage desktop-empty">
<div class="empty-glow" />
<UiIcon name="chat" :size="48" />
<h2>یک مکالمه را انتخاب کنید</h2>
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
</div>
<template v-else>
<div class="stage-top">
2026-09-29 21:26:07 +03:30
<template v-if="selectMode">
<button type="button" class="back-btn show" aria-label="انصراف" @click="exitSelectMode">
<UiIcon name="x" :size="20" />
</button>
<div class="stage-titles">
<h2>{{ toFaDigits(selectedCount) }} انتخاب‌شده</h2>
</div>
<div class="select-actions">
<button
type="button"
class="select-act"
title="ارسال برای دیگری"
:disabled="!selectedCount"
@click="openForwardPicker()"
>
<UiIcon name="send" :size="18" />
</button>
<button
type="button"
class="select-act danger"
title="حذف"
:disabled="!selectedCount"
@click="deleteSelected"
>
<UiIcon name="trash" :size="18" />
</button>
</div>
</template>
<template v-else-if="chatSearchOpen">
<button type="button" class="back-btn show" aria-label="بستن جستجو" @click="closeChatSearch">
<UiIcon name="arrow-right" :size="20" />
</button>
<label class="chat-search-field">
<UiIcon name="search" :size="16" />
<input
v-model="chatSearchQuery"
type="search"
placeholder="جستجو در گفتگو…"
autocomplete="off"
autofocus
/>
</label>
<div class="chat-search-nav">
<span class="chat-search-count">
{{
chatSearchHitCount
? `${toFaDigits(chatSearchIndex + 1)}/${toFaDigits(chatSearchHitCount)}`
: '۰'
}}
</span>
<button
type="button"
class="select-act"
title="قبلی"
:disabled="!chatSearchHitCount"
@click="goSearchHit(-1)"
>
<UiIcon name="chevron" :size="16" />
</button>
<button
type="button"
class="select-act search-next"
title="بعدی"
:disabled="!chatSearchHitCount"
@click="goSearchHit(1)"
>
<UiIcon name="chevron" :size="16" />
</button>
</div>
</template>
2026-09-29 21:26:07 +03:30
<template v-else>
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
<UiIcon name="arrow-right" :size="20" />
</button>
<span class="stage-avatar">
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
</span>
<div class="stage-titles">
<h2>{{ roomTitle(activeRoom) }}</h2>
2026-09-30 11:08:33 +03:30
<span class="kind-tag" :data-kind="activeRoom.kind">{{
roomKindLabel(activeRoom.kind)
}}</span>
2026-09-29 21:26:07 +03:30
</div>
<div class="stage-tools">
<button
v-if="activeRoom.kind !== 'dm'"
type="button"
class="select-act"
title="اعضا"
@click="openMembersPanel"
>
<UiIcon name="users" :size="18" />
</button>
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
<UiIcon name="search" :size="18" />
</button>
<button
type="button"
class="select-act"
:class="{ on: activeRoom.pinned }"
:title="activeRoom.pinned ? 'برداشتن سنجاق گفتگو' : 'سنجاق گفتگو'"
@click="toggleRoomPinned"
>
<UiIcon name="pin" :size="18" />
</button>
</div>
2026-09-29 21:26:07 +03:30
</template>
</div>
<div
v-if="pinnedMessage && !chatSearchOpen && !selectMode"
class="pin-banner"
>
<button
type="button"
class="pin-banner-main"
@click="scrollToMessage(pinnedMessage.messageId)"
>
<UiIcon name="pin" :size="16" />
<span class="pin-banner-text">{{ pinnedMessage.preview || 'پیام سنجاق‌شده' }}</span>
</button>
<button
type="button"
class="pin-banner-x"
aria-label="برداشتن سنجاق"
@click="clearPinnedMessage"
>
<UiIcon name="x" :size="14" />
</button>
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
<ul
v-else
ref="msgStreamEl"
class="msg-stream"
:class="{ selecting: selectMode }"
@scroll.passive="onMsgStreamScroll"
>
<li v-if="loadingOlderMessages" class="history-loading">در حال بارگذاری پیام‌های قبلی…</li>
<li v-else-if="hasMoreMessages" class="history-hint">برای دیدن پیام‌های قدیمی‌تر بالا بکشید</li>
<li
v-for="msg in messages"
:key="msg.id"
class="msg-row"
:data-msg-id="msg.id"
2026-09-29 21:26:07 +03:30
:class="{
mine: msg.senderId === chatUserId || msg.optimistic,
selected: isSelected(msg.id),
'search-hit': activeSearchHitId === msg.id,
2026-09-29 21:26:07 +03:30
}"
2026-09-29 21:06:04 +03:30
@contextmenu="openMsgMenu(msg, $event)"
2026-09-29 21:26:07 +03:30
@click="selectMode ? toggleSelect(msg) : undefined"
>
2026-09-29 21:26:07 +03:30
<button
v-if="selectMode"
type="button"
class="select-check"
:class="{ on: isSelected(msg.id) }"
:aria-label="isSelected(msg.id) ? 'برداشتن انتخاب' : 'انتخاب'"
@click.stop="toggleSelect(msg)"
>
<UiIcon v-if="isSelected(msg.id)" name="check-plain" :size="14" />
</button>
2026-09-29 21:06:04 +03:30
<div
class="bubble"
:class="{
2026-09-29 21:36:31 +03:30
media: msg.type !== 'text',
2026-09-29 21:06:04 +03:30
}"
@touchstart.passive="onBubbleTouchStart(msg, $event)"
@touchend="onBubbleTouchEnd"
@touchmove="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd"
>
<button
v-if="msg.reply"
type="button"
class="reply-quote"
@click.stop="scrollToMessage(msg.reply)"
>
<span class="reply-quote-label">پاسخ به</span>
<span class="reply-quote-text">{{ replyPreviewText(msg.reply) }}</span>
</button>
2026-09-29 21:36:31 +03:30
<template v-if="msg.type === 'image'">
2026-09-29 17:22:34 +03:30
<button
type="button"
class="media-image"
:disabled="!mediaSrc(msg)"
@click="lightboxUrl = mediaSrc(msg)"
>
<img :src="mediaSrc(msg)" :alt="msg.fileName || 'تصویر'" />
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-veil">
{{ toFaDigits(msg.uploadProgress) }}٪
</span>
</button>
</template>
<template v-else-if="msg.type === 'voice'">
<audio class="media-audio" :src="mediaSrc(msg)" controls preload="metadata" />
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
</template>
<template v-else-if="msg.type === 'video'">
<video
v-if="mediaSrc(msg)"
class="media-video"
:src="mediaSrc(msg)"
controls
playsinline
preload="metadata"
/>
<a
v-else
class="media-file"
:href="msg.content"
target="_blank"
rel="noopener"
>
<UiIcon name="document" :size="18" />
<span>{{ msg.fileName || 'ویدیو' }}</span>
</a>
2026-09-29 17:22:34 +03:30
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
</template>
<template v-else-if="msg.type === 'file'">
<a
class="media-file"
:href="mediaSrc(msg)"
target="_blank"
rel="noopener"
:download="msg.fileName || true"
>
<UiIcon name="document" :size="18" />
<span>{{ msg.fileName || 'دانلود فایل' }}</span>
</a>
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
</span>
</template>
<p v-else class="msg-text">
<template
v-for="(seg, si) in messageTextSegments(
msg.content,
chatSearchOpen ? chatSearchQuery : '',
)"
:key="si"
>
<span v-if="seg.mention" class="mention">{{ seg.text }}</span>
<mark v-else-if="seg.hit" class="search-mark">{{ seg.text }}</mark>
<template v-else>{{ seg.text }}</template>
</template>
</p>
<div class="bubble-meta">
2026-09-29 21:36:31 +03:30
<span v-if="msg.edited" class="edited-mark">ویرایش‌شده</span>
<time>{{ formatListTime(msg.createdAt) }}</time>
<span
v-if="msg.senderId === chatUserId || msg.optimistic"
class="ticks"
:class="msg.delivery"
:title="
msg.delivery === 'read'
? 'خوانده شد'
: msg.delivery === 'sent'
? 'ارسال شد'
: 'در حال ارسال'
"
>{{ deliveryLabel(msg.delivery) }}</span>
</div>
</div>
</li>
</ul>
<div v-if="typingLabel" class="typing-bar">
<span class="typing-dots" aria-hidden="true"><i /><i /><i /></span>
{{ typingLabel }}
</div>
2026-09-30 11:08:33 +03:30
<div v-if="!canPostInActive" class="channel-readonly">
<UiIcon name="megaphone" :size="18" />
<span>فقط مدیران کانال می‌توانند پیام بفرستند</span>
</div>
<form v-else class="composer" @submit.prevent="submitMessage">
<div v-if="mentionState && mentionCandidates.length" class="mention-sheet">
<button
v-for="(peer, idx) in mentionCandidates"
:key="peer.id"
type="button"
class="mention-item"
:class="{ active: idx === mentionIndex }"
@click="insertMention(peer)"
>
<UserAvatar :name="peer.fullName" :size="28" />
<span>{{ peer.fullName }}</span>
</button>
</div>
2026-09-29 21:06:04 +03:30
<div v-if="emojiOpen" class="emoji-panel">
<button
v-for="mark in CHAT_EMOJIS"
:key="mark"
type="button"
class="emoji-btn"
@click="insertEmoji(mark)"
>
{{ mark }}
</button>
</div>
<div v-if="editingMessageId" class="edit-banner">
<UiIcon name="edit" :size="16" />
<span>ویرایش پیام</span>
<button type="button" class="edit-cancel" @click="cancelEdit(); draft = ''">انصراف</button>
</div>
<div v-else-if="replyTo" class="edit-banner reply-banner">
<UiIcon name="chat" :size="16" />
<div class="reply-banner-body">
<strong>پاسخ به پیام</strong>
<span>{{ replyPreviewText(replyTo.id) }}</span>
</div>
<button type="button" class="edit-cancel" @click="cancelReply">انصراف</button>
</div>
2026-09-29 17:22:34 +03:30
<div v-if="pendingAttaches.length" class="attach-preview">
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
<video v-else-if="item.kind === 'video'" :src="item.previewUrl" muted />
<span v-else class="attach-chip-label">
<UiIcon :name="item.kind === 'voice' ? 'mic' : 'document'" :size="14" />
{{ item.name }}
</span>
<button type="button" class="attach-remove" aria-label="حذف" @click="removePending(item.id)">
<UiIcon name="x" :size="12" />
</button>
</div>
</div>
<div v-if="recording" class="rec-bar">
<span class="rec-dot" />
در حال ضبط {{ toFaDigits(recordLabel()) }}
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
</div>
<div class="composer-row">
<input
ref="fileInputEl"
type="file"
class="sr-only"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
@change="onFilePicked"
/>
<button
2026-09-29 21:06:04 +03:30
v-if="showMicInsteadOfSend"
2026-09-29 17:22:34 +03:30
type="button"
2026-09-29 21:06:04 +03:30
class="tool-btn mic-btn"
2026-09-29 17:22:34 +03:30
:class="{ on: recording }"
title="ضبط پیام صوتی"
2026-09-29 17:22:34 +03:30
:disabled="sending"
@click="toggleVoiceRecord"
>
2026-09-29 21:06:04 +03:30
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
2026-09-29 17:22:34 +03:30
</button>
<button
2026-09-29 21:06:04 +03:30
v-else
2026-09-29 17:22:34 +03:30
type="submit"
class="send-btn"
2026-09-29 21:06:04 +03:30
:disabled="sending || recording || !canSend"
2026-09-29 17:22:34 +03:30
>
<UiIcon name="send" :size="18" />
</button>
<div class="input-shell">
<textarea
ref="draftInputEl"
v-model="draft"
class="composer-input"
rows="1"
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
autocomplete="off"
2026-09-30 10:04:54 +03:30
enterkeyhint="enter"
:disabled="sending || recording"
@focus="emojiOpen = false"
@input="onDraftInput"
@click="onDraftInput"
@keyup="onDraftInput"
@keydown="onComposerKeydown"
/>
</div>
<button
type="button"
class="emoji-toggle"
title="ایموجی"
:class="{ on: emojiOpen }"
:disabled="sending || recording"
@click="emojiOpen = !emojiOpen"
>
😊
</button>
<button
type="button"
class="tool-btn attach-btn"
title="پیوست"
:disabled="sending || recording || Boolean(editingMessageId)"
@click="fileInputEl?.click()"
>
<UiIcon name="plus" :size="20" />
</button>
2026-09-29 17:22:34 +03:30
</div>
</form>
2026-09-29 21:06:04 +03:30
<Teleport to="body">
<div
v-if="msgMenu"
class="msg-menu-backdrop"
@click="msgMenu = null"
@touchstart.passive="msgMenu = null"
/>
<div
v-if="msgMenu && menuMessage()"
ref="msgMenuEl"
class="msg-menu"
:class="{ sheet: msgMenu.sheet }"
:style="
msgMenu.sheet
? undefined
: { top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }
"
2026-09-29 21:26:07 +03:30
>
<button type="button" @click="startReplyMessage(menuMessage()!)">
<UiIcon name="chat" :size="16" />
پاسخ
</button>
<button type="button" @click="togglePinMessage(menuMessage()!)">
<UiIcon name="pin" :size="16" />
{{
pinnedMessage?.messageId === menuMessage()?.id
? 'برداشتن سنجاق'
: 'سنجاق کردن'
}}
</button>
<button type="button" @click="enterSelectMode(menuMessage()!)">
<UiIcon name="check-plain" :size="16" />
انتخاب
</button>
<button type="button" @click="openForwardPicker([menuMessage()!])">
<UiIcon name="send" :size="16" />
ارسال برای دیگری
</button>
<button type="button" @click="openConvertToActivity(menuMessage()!)">
<UiIcon name="check" :size="16" />
تبدیل به فعالیت
</button>
<button
v-if="isMineMessage(menuMessage()) && menuMessage()?.type === 'text'"
type="button"
@click="startEditMessage(menuMessage()!)"
>
<UiIcon name="edit" :size="16" />
ویرایش
</button>
<button
v-if="isMineMessage(menuMessage())"
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف برای همه
</button>
<button
v-else
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف فقط برای من
</button>
</div>
</Teleport>
</template>
</section>
</div>
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
<div class="modal-card wide">
<h3>گفتگوی جدید</h3>
<p class="hint">همکار موردنظر را انتخاب کنید</p>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی نام…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: dmPeerId === p.id }"
@click="dmPeerId = p.id"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>گفتگوی خصوصی</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="dmPeerId === p.id" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showNewDm = false">انصراف</button>
<button type="button" class="primary" :disabled="!dmPeerId" @click="startDm">شروع</button>
</div>
</div>
</dialog>
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
<div class="modal-card wide">
<h3>گروه جدید</h3>
<input v-model="groupName" type="text" placeholder="نام گروه" />
<div class="picker-head">
<p class="hint">اعضا را انتخاب کنید</p>
<span v-if="groupMemberIds.length" class="picker-count">
{{ toFaDigits(groupMemberIds.length) }} نفر
</span>
</div>
<div v-if="selectedGroupPeers.length" class="picked-chips">
<button
v-for="p in selectedGroupPeers"
:key="p.id"
type="button"
class="picked-chip"
:title="`حذف ${p.fullName}`"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی همکار…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: groupMemberIds.includes(p.id) }"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ groupMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="groupMemberIds.includes(p.id)" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showNewGroup = false">انصراف</button>
<button
type="button"
class="primary"
:disabled="!groupName.trim() || !groupMemberIds.length"
@click="startGroup"
>
ساخت گروه
</button>
</div>
</div>
</dialog>
2026-09-29 17:22:34 +03:30
2026-09-30 11:08:33 +03:30
<dialog v-if="showNewChannel" class="modal" open @click.self="showNewChannel = false">
<div class="modal-card wide">
<h3>کانال جدید</h3>
<p class="hint channel-hint">
کانال یک‌طرفه است؛ فقط شما و مدیران سازمان می‌توانند پیام بفرستند.
</p>
2026-09-30 11:08:33 +03:30
<input v-model="channelName" type="text" placeholder="نام کانال" />
<div class="picker-head">
<p class="hint">مشاهده‌کنندگان</p>
<span v-if="channelMemberIds.length" class="picker-count">
{{ toFaDigits(channelMemberIds.length) }} نفر
</span>
</div>
<div v-if="selectedChannelPeers.length" class="picked-chips">
<button
v-for="p in selectedChannelPeers"
:key="p.id"
type="button"
class="picked-chip"
:title="`حذف ${p.fullName}`"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="peerPickerQuery"
type="search"
placeholder="جستجوی همکار…"
autocomplete="off"
/>
</label>
<div class="peer-list">
<button
v-for="p in filteredPeers"
:key="p.id"
type="button"
class="peer-row"
:class="{ on: channelMemberIds.includes(p.id) }"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ channelMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
</span>
<span class="peer-tick" aria-hidden="true">
<UiIcon v-if="channelMemberIds.includes(p.id)" name="check-plain" :size="14" />
</span>
</button>
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
2026-09-30 11:08:33 +03:30
</div>
<div class="modal-actions">
<button type="button" @click="showNewChannel = false">انصراف</button>
<button
type="button"
class="primary"
:disabled="!channelName.trim() || !channelMemberIds.length"
@click="startChannel"
>
ساخت کانال
</button>
2026-09-30 11:08:33 +03:30
</div>
</div>
</dialog>
<dialog
v-if="showActivityFromMsg"
class="modal"
open
@click.self="closeActivityFromMsg"
>
<div class="modal-card activity-sheet">
<template v-if="!activityCreatedId">
<h3>تبدیل به فعالیت</h3>
<p class="hint">از این پیام یک فعالیت در CRM ساخته می‌شود.</p>
<label class="field-label">عنوان</label>
<input v-model="activityTitle" type="text" placeholder="عنوان فعالیت" />
<label class="field-label">نوع</label>
<div class="type-chips">
<button
v-for="opt in ACTIVITY_TYPE_OPTS"
:key="opt.value"
type="button"
class="type-chip"
:class="{ on: activityType === opt.value }"
@click="activityType = opt.value"
>
{{ opt.label }}
</button>
</div>
<p v-if="activityError" class="act-err">{{ activityError }}</p>
<div class="modal-actions">
<button type="button" :disabled="activityBusy" @click="closeActivityFromMsg">
انصراف
</button>
<button
type="button"
class="primary"
:disabled="activityBusy || !activityTitle.trim()"
@click="submitActivityFromMsg"
>
{{ activityBusy ? '…' : 'ساخت فعالیت' }}
</button>
</div>
</template>
<template v-else>
<h3>فعالیت ساخته شد</h3>
<p class="hint">می‌توانید جزئیات را در صفحه فعالیت کامل کنید.</p>
<div class="modal-actions">
<button type="button" @click="closeActivityFromMsg">بستن</button>
<button type="button" class="primary" @click="openCreatedActivity">مشاهده فعالیت</button>
</div>
</template>
</div>
</dialog>
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
<div class="modal-card wide members-sheet">
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
<p v-if="activeRoom?.kind === 'channel'" class="hint channel-hint">
ادمین‌ها می‌توانند در کانال پیام بفرستند؛ بقیه فقط مشاهده می‌کنند.
</p>
<div v-if="canManageActiveMembers" class="member-add-row">
<select v-model="memberAddId" :disabled="membersBusy || !membersAddCandidates.length">
<option value="">افزودن همکار…</option>
<option v-for="p in membersAddCandidates" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
<button
type="button"
class="primary"
:disabled="membersBusy || !memberAddId"
@click="addRoomMember"
>
افزودن
</button>
</div>
<p v-if="membersError" class="act-err">{{ membersError }}</p>
<div v-if="membersBusy && !roomMembers.length" class="muted center pad">در حال بارگذاری…</div>
<div v-else class="peer-list members-list">
<div v-for="m in roomMembers" :key="m.userId" class="peer-row member-static">
<UserAvatar :name="m.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ m.fullName }}{{ m.isMe ? ' (شما)' : '' }}</strong>
<small>{{ m.role === 'admin' ? 'ادمین' : 'عضو' }}</small>
</span>
<div v-if="canManageActiveMembers && !m.isMe" class="member-acts">
<button
type="button"
class="member-act"
:disabled="membersBusy"
@click="toggleMemberAdmin(m)"
>
{{ m.role === 'admin' ? 'برداشتن ادمین' : 'ادمین کردن' }}
</button>
<button
type="button"
class="member-act danger"
:disabled="membersBusy"
@click="removeRoomMember(m.userId)"
>
حذف
</button>
</div>
</div>
<p v-if="!roomMembers.length" class="peer-empty">عضوی ثبت نشده</p>
</div>
<div class="modal-actions">
<button type="button" @click="showMembers = false">بستن</button>
</div>
</div>
</dialog>
2026-09-29 21:26:07 +03:30
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
<div class="modal-card">
<h3>ارسال برای دیگری</h3>
<p class="hint">{{ toFaDigits(selectedCount) }} پیام انتخاب شده</p>
<div v-if="!forwardTargets.length" class="muted">گفتگوی دیگری برای ارسال نیست.</div>
<div v-else class="forward-list">
<button
v-for="room in forwardTargets"
:key="room.externalRoomId"
type="button"
class="forward-item"
:disabled="forwardBusy"
@click="confirmForward(room.externalRoomId)"
>
<UserAvatar :name="roomTitle(room)" :size="36" />
<span>{{ roomTitle(room) }}</span>
</button>
</div>
<div class="modal-actions">
<button type="button" :disabled="forwardBusy" @click="showForward = false">انصراف</button>
</div>
</div>
</dialog>
2026-09-29 17:22:34 +03:30
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
<UiIcon name="x" :size="18" />
</button>
<img :src="lightboxUrl" alt="" />
</dialog>
</div>
</template>
<style scoped>
.conv-page {
position: relative;
min-height: calc(100vh - 4rem);
padding: 1rem 1.25rem 1.5rem;
overflow: hidden;
}
.conv-aurora {
position: absolute;
inset: -20% -10% auto;
height: 320px;
background:
radial-gradient(ellipse 60% 80% at 85% 0%, rgba(244, 114, 182, 0.22), transparent 60%),
radial-gradient(ellipse 50% 70% at 10% 10%, rgba(45, 212, 191, 0.2), transparent 55%);
pointer-events: none;
z-index: 0;
}
.conv-head {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.mobile-list-head {
display: none;
}
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 0.25rem;
}
.conv-head h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 800;
background: linear-gradient(120deg, #0f766e, #7c3aed 55%, #db2777);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sub {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.head-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ws-pill {
font-size: 0.75rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
.ws-pill[data-state='open'] {
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
}
.err-banner {
background: #fef2f2;
color: #b91c1c;
padding: 0.65rem 0.85rem;
border-radius: 12px;
margin-bottom: 0.75rem;
}
.conv-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 0.75rem;
min-height: 520px;
height: min(72vh, 720px);
}
.conv-rail {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 20px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
}
.rail-actions {
display: flex;
2026-09-30 11:08:33 +03:30
gap: 0.35rem;
flex-wrap: wrap;
}
.rail-filters {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0 0.1rem;
}
.rail-search {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.65rem;
border-radius: 12px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.2);
color: var(--muted);
}
.rail-search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 0.85rem;
color: var(--text);
}
.rail-search input::placeholder {
color: var(--muted);
}
.rail-tabs {
display: grid;
2026-09-30 11:08:33 +03:30
grid-template-columns: repeat(4, 1fr);
gap: 0.2rem;
padding: 0.2rem;
border-radius: 12px;
background: rgba(148, 163, 184, 0.1);
}
.rail-tabs button {
border: none;
background: transparent;
border-radius: 9px;
2026-09-30 11:08:33 +03:30
padding: 0.35rem 0.15rem;
font: inherit;
2026-09-30 11:08:33 +03:30
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.rail-tabs button.active {
background: #fff;
color: #0f766e;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.rail-empty {
padding: 1.25rem 0.75rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
.fab {
flex: 1;
border: none;
border-radius: 14px;
padding: 0.55rem 0.65rem;
font-weight: 600;
cursor: pointer;
background: linear-gradient(135deg, #0d9488, #6366f1);
color: #fff;
}
.fab.ghost {
background: transparent;
border: 1px dashed rgba(99, 102, 241, 0.45);
color: #4338ca;
}
.room-list {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
.room-chip {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto auto;
gap: 0.15rem 0.65rem;
align-items: center;
text-align: right;
border: none;
background: transparent;
padding: 0.55rem 0.45rem;
border-radius: 14px;
cursor: pointer;
transition: background 0.15s ease;
}
.room-chip:hover,
.room-chip.active {
background: rgba(99, 102, 241, 0.08);
}
.avatar-ring {
grid-row: span 2;
border-radius: 999px;
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.room-meta {
min-width: 0;
}
.room-meta strong {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.92rem;
}
.room-pin-ic {
color: #0d9488;
flex-shrink: 0;
}
.room-meta small {
color: var(--muted);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: block;
}
.unread {
grid-column: 3;
grid-row: 1;
background: #db2777;
color: #fff;
font-size: 0.7rem;
min-width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
display: grid;
place-items: center;
}
.room-chip time {
grid-column: 3;
grid-row: 2;
font-size: 0.68rem;
color: var(--muted);
}
.conv-stage {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(14px);
border-radius: 22px;
border: 1px solid rgba(148, 163, 184, 0.22);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.stage-top {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
gap: 0.55rem;
flex-shrink: 0;
}
.back-btn {
display: none;
border: none;
background: transparent;
color: var(--text);
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
2026-09-29 21:26:07 +03:30
.back-btn.show {
display: grid;
}
.stage-avatar {
flex-shrink: 0;
border-radius: 999px;
padding: 2px;
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
}
.stage-titles {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.stage-top h2 {
margin: 0;
font-size: 1.05rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
2026-09-29 21:26:07 +03:30
.select-actions {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.select-act {
border: none;
background: transparent;
color: #0f766e;
width: 2.35rem;
height: 2.35rem;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
}
.select-act.danger {
color: #dc2626;
}
.select-act:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.select-act.on {
background: rgba(13, 148, 136, 0.12);
color: #0f766e;
}
.stage-tools {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 0.15rem;
flex-shrink: 0;
}
.chat-search-field {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.14);
color: var(--muted);
}
.chat-search-field input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 0.9rem;
color: var(--text);
}
.chat-search-nav {
display: flex;
align-items: center;
gap: 0.1rem;
flex-shrink: 0;
}
.chat-search-count {
font-size: 0.75rem;
color: var(--muted);
min-width: 2.4rem;
text-align: center;
}
.chat-search-nav .select-act svg {
transform: rotate(-90deg);
}
.chat-search-nav .search-next svg {
transform: rotate(90deg);
}
.pin-banner {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, rgba(13, 148, 136, 0.1), rgba(99, 102, 241, 0.06));
flex-shrink: 0;
}
.pin-banner-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.45rem;
border: none;
background: transparent;
color: #0f766e;
text-align: right;
cursor: pointer;
font: inherit;
padding: 0;
}
.pin-banner-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.82rem;
font-weight: 600;
}
.pin-banner-x {
border: none;
background: transparent;
color: var(--muted);
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.msg-row.search-hit .bubble {
outline: 2px solid rgba(13, 148, 136, 0.55);
outline-offset: 2px;
}
.msg-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.mention {
color: #0f766e;
font-weight: 700;
background: rgba(13, 148, 136, 0.12);
border-radius: 4px;
padding: 0 0.15rem;
}
.search-mark {
background: #fde68a;
color: inherit;
border-radius: 3px;
padding: 0 0.05rem;
}
.mention-sheet {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 220px;
overflow: auto;
margin-bottom: 0.35rem;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.mention-item {
display: flex;
align-items: center;
gap: 0.55rem;
border: none;
background: transparent;
padding: 0.55rem 0.7rem;
text-align: right;
cursor: pointer;
font: inherit;
color: var(--text);
}
.mention-item.active,
.mention-item:hover {
background: rgba(13, 148, 136, 0.08);
}
2026-09-29 21:26:07 +03:30
.forward-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 280px;
overflow-y: auto;
margin: 0.75rem 0;
}
.forward-item {
display: flex;
align-items: center;
gap: 0.65rem;
border: 1px solid var(--line);
background: #fff;
border-radius: 12px;
padding: 0.55rem 0.7rem;
font: inherit;
cursor: pointer;
text-align: right;
}
.forward-item:hover,
.forward-item:active {
background: rgba(13, 148, 136, 0.06);
}
.forward-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kind-tag {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: rgba(124, 58, 237, 0.1);
color: #6d28d9;
width: fit-content;
}
2026-09-30 11:08:33 +03:30
.kind-tag[data-kind='dm'] {
background: rgba(13, 148, 136, 0.12);
color: #0f766e;
}
.kind-tag[data-kind='group'] {
background: rgba(99, 102, 241, 0.12);
color: #4338ca;
}
.kind-tag[data-kind='channel'] {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
font-weight: 700;
}
.channel-readonly {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.85rem 1rem calc(0.85rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: #fff7ed;
color: #9a3412;
font-size: 0.86rem;
font-weight: 600;
flex-shrink: 0;
text-align: center;
}
.typing-bar {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 1rem;
font-size: 0.8rem;
color: #0f766e;
background: rgba(13, 148, 136, 0.06);
border-top: 1px solid rgba(13, 148, 136, 0.12);
flex-shrink: 0;
}
.typing-dots {
display: inline-flex;
gap: 3px;
align-items: center;
}
.typing-dots i {
width: 5px;
height: 5px;
border-radius: 999px;
background: #0d9488;
animation: typingPulse 1.2s infinite ease-in-out;
}
.typing-dots i:nth-child(2) {
animation-delay: 0.15s;
}
.typing-dots i:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes typingPulse {
0%,
80%,
100% {
opacity: 0.3;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
.history-loading,
.history-hint {
text-align: center;
color: var(--muted);
font-size: 0.78rem;
padding: 0.35rem 0.5rem 0.75rem;
list-style: none;
}
.member-add-row {
display: flex;
gap: 0.45rem;
align-items: center;
}
.member-add-row select {
flex: 1;
min-width: 0;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
font: inherit;
}
.member-add-row .primary {
border: none;
background: #0d9488;
color: #fff;
border-radius: 10px;
padding: 0.55rem 0.85rem;
font: inherit;
cursor: pointer;
white-space: nowrap;
}
.member-static {
cursor: default;
}
.member-acts {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: stretch;
}
.member-act {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 0.25rem 0.45rem;
font: inherit;
font-size: 0.72rem;
cursor: pointer;
white-space: nowrap;
color: #0f766e;
}
.member-act.danger {
color: #b91c1c;
}
.members-list {
max-height: 280px;
2026-09-30 11:08:33 +03:30
}
.msg-stream {
flex: 1;
list-style: none;
margin: 0;
padding: 1rem 1.15rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.65rem;
min-height: 0;
background:
radial-gradient(circle at 12% 8%, rgba(45, 212, 191, 0.07), transparent 40%),
radial-gradient(circle at 88% 92%, rgba(167, 139, 250, 0.08), transparent 42%);
}
.msg-row {
display: flex;
justify-content: flex-start;
2026-09-29 21:26:07 +03:30
align-items: flex-end;
gap: 0.35rem;
}
.msg-row.mine {
justify-content: flex-end;
}
2026-09-29 21:26:07 +03:30
.msg-row.selected .bubble {
outline: 2px solid rgba(13, 148, 136, 0.45);
}
.msg-stream.selecting .msg-row {
cursor: pointer;
}
.select-check {
flex: 0 0 auto;
width: 1.35rem;
height: 1.35rem;
border-radius: 999px;
border: 2px solid #94a3b8;
background: #fff;
color: #fff;
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
margin-bottom: 0.35rem;
}
.select-check.on {
border-color: #0d9488;
background: #0d9488;
}
.bubble {
max-width: min(420px, 85%);
padding: 0.65rem 0.85rem;
border-radius: 18px 18px 18px 4px;
background: #f1f5f9;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.msg-row.mine .bubble {
border-radius: 18px 18px 4px 18px;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
}
.bubble p {
margin: 0 0 0.25rem;
white-space: pre-wrap;
word-break: break-word;
}
2026-09-29 17:22:34 +03:30
.bubble.media {
padding: 0.45rem;
}
.media-image {
position: relative;
display: block;
border: none;
padding: 0;
background: transparent;
cursor: zoom-in;
border-radius: 12px;
overflow: hidden;
max-width: min(280px, 72vw);
}
.media-image img {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
}
.upload-veil {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.45);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
}
.media-audio {
display: block;
width: min(280px, 72vw);
max-width: 100%;
height: 40px;
border-radius: 20px;
}
2026-09-29 17:22:34 +03:30
.media-video {
display: block;
width: min(280px, 72vw);
max-width: 100%;
border-radius: 12px;
background: #0f172a;
}
.media-file {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.65rem;
border-radius: 12px;
background: rgba(15, 23, 42, 0.06);
color: inherit;
text-decoration: none;
font-size: 0.88rem;
max-width: min(280px, 72vw);
}
.media-file span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-line {
display: block;
margin-top: 0.35rem;
font-size: 0.72rem;
color: var(--muted);
}
.bubble-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
2026-09-29 17:22:34 +03:30
margin-top: 0.25rem;
padding-inline: 0.25rem;
}
.bubble time {
font-size: 0.68rem;
color: var(--muted);
}
.ticks {
font-size: 0.72rem;
letter-spacing: -0.08em;
line-height: 1;
color: #94a3b8;
user-select: none;
}
.ticks.sending {
color: #cbd5e1;
}
.ticks.sent {
color: #64748b;
}
.ticks.read {
color: #0d9488;
}
.composer {
display: flex;
2026-09-29 17:22:34 +03:30
flex-direction: column;
2026-09-29 21:06:04 +03:30
gap: 0.4rem;
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
2026-09-29 21:06:04 +03:30
background: #f0f2f5;
flex-shrink: 0;
position: relative;
}
2026-09-29 17:22:34 +03:30
.composer-row {
display: flex;
2026-09-29 21:06:04 +03:30
gap: 0.35rem;
align-items: flex-end;
min-width: 0;
2026-09-29 17:22:34 +03:30
}
2026-09-29 21:06:04 +03:30
.input-shell {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: flex-end;
2026-09-29 21:06:04 +03:30
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 22px;
padding: 0.35rem 0.75rem;
2026-09-29 21:06:04 +03:30
min-height: 2.75rem;
}
.composer-input {
flex: 1 1 auto;
min-width: 0;
2026-09-29 21:06:04 +03:30
border: none;
outline: none;
border-radius: 18px;
padding: 0.35rem 0;
font: inherit;
2026-09-29 21:06:04 +03:30
font-size: 0.95rem;
background: transparent;
resize: none;
max-height: 7.5rem;
line-height: 1.45;
field-sizing: content;
2026-09-29 21:06:04 +03:30
}
.emoji-toggle {
flex: 0 0 auto;
border: none;
background: #fff;
border: 1px solid #e2e8f0;
width: 2.75rem;
height: 2.75rem;
2026-09-29 21:06:04 +03:30
border-radius: 999px;
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.emoji-toggle.on {
background: rgba(13, 148, 136, 0.12);
border-color: rgba(13, 148, 136, 0.35);
2026-09-29 21:06:04 +03:30
}
.emoji-panel {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 0.2rem;
max-height: 9.5rem;
overflow-y: auto;
padding: 0.45rem;
border-radius: 14px;
background: #fff;
2026-09-29 21:06:04 +03:30
border: 1px solid #e2e8f0;
}
.emoji-btn {
border: none;
background: transparent;
border-radius: 10px;
font-size: 1.35rem;
line-height: 1.4;
cursor: pointer;
padding: 0.2rem;
}
.emoji-btn:hover,
.emoji-btn:active {
background: #f1f5f9;
}
.edit-banner {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.65rem;
border-radius: 12px;
background: rgba(13, 148, 136, 0.1);
color: #0f766e;
font-size: 0.85rem;
}
.edit-cancel {
margin-inline-start: auto;
border: none;
background: transparent;
color: #0f766e;
font: inherit;
cursor: pointer;
font-weight: 600;
}
.tool-btn,
.send-btn {
flex: 0 0 auto;
2026-09-29 21:06:04 +03:30
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
display: grid;
place-items: center;
cursor: pointer;
2026-09-29 17:22:34 +03:30
}
.tool-btn {
2026-09-29 21:06:04 +03:30
border: none;
background: transparent;
color: #64748b;
}
.tool-btn.attach-btn,
.tool-btn.mic-btn {
background: #fff;
border: 1px solid #e2e8f0;
color: #334155;
2026-09-29 17:22:34 +03:30
}
.tool-btn.on {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.tool-btn:disabled,
.send-btn:disabled {
2026-09-29 17:22:34 +03:30
opacity: 0.45;
cursor: not-allowed;
}
.send-btn {
border: none;
background: #0d9488;
color: #fff;
2026-09-29 21:06:04 +03:30
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.28);
}
.bubble.deleted {
opacity: 0.72;
font-style: italic;
}
.deleted-text {
margin: 0;
color: #64748b;
font-size: 0.88rem;
}
.edited-mark {
font-size: 0.68rem;
color: #94a3b8;
}
.msg-menu-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(15, 23, 42, 0.28);
2026-09-29 21:06:04 +03:30
}
.msg-menu {
position: fixed;
z-index: 1210;
min-width: 11rem;
max-width: min(18rem, calc(100vw - 1.5rem));
2026-09-29 21:06:04 +03:30
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.35rem;
border-radius: 14px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
}
.msg-menu.sheet {
left: 50%;
right: auto;
top: auto;
bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px));
transform: translateX(-50%);
width: min(22rem, calc(100vw - 1.5rem));
max-width: none;
padding: 0.45rem;
border-radius: 18px;
2026-09-29 21:06:04 +03:30
}
.msg-menu button {
display: flex;
align-items: center;
gap: 0.45rem;
border: none;
background: transparent;
padding: 0.65rem 0.75rem;
2026-09-29 21:06:04 +03:30
border-radius: 10px;
font: inherit;
cursor: pointer;
color: #0f172a;
text-align: right;
}
.msg-menu button:hover,
.msg-menu button:active {
background: #f8fafc;
}
.msg-menu button.danger {
color: #dc2626;
}
.reply-quote {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
width: 100%;
margin: 0 0 0.4rem;
padding: 0.4rem 0.55rem;
border: none;
border-inline-start: 3px solid #0d9488;
border-radius: 10px;
background: rgba(15, 23, 42, 0.05);
color: inherit;
font: inherit;
cursor: pointer;
text-align: right;
}
.reply-quote-label {
font-size: 0.72rem;
color: #0f766e;
font-weight: 700;
}
.reply-quote-text {
font-size: 0.82rem;
color: #475569;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.reply-banner-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
flex: 1;
}
.reply-banner-body strong {
font-size: 0.82rem;
}
.reply-banner-body span {
font-size: 0.78rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.msg-row.flash .bubble {
outline: 2px solid rgba(13, 148, 136, 0.55);
transition: outline-color 0.2s ease;
}
2026-09-29 17:22:34 +03:30
.attach-preview {
display: flex;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.attach-chip {
position: relative;
flex: 0 0 auto;
border-radius: 12px;
border: 1px solid var(--line);
overflow: hidden;
background: #f8fafc;
}
.attach-chip.image,
.attach-chip.video {
width: 72px;
height: 72px;
}
.attach-chip img,
.attach-chip video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attach-chip-label {
display: flex;
align-items: center;
gap: 0.35rem;
max-width: 160px;
padding: 0.55rem 0.7rem;
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attach-remove {
position: absolute;
top: 4px;
left: 4px;
border: none;
border-radius: 999px;
width: 1.35rem;
height: 1.35rem;
background: rgba(15, 23, 42, 0.7);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.rec-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #b91c1c;
}
.rec-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: #ef4444;
animation: pulse 1s infinite;
}
.rec-stop {
margin-inline-start: auto;
border: 1px solid #fecaca;
background: #fff;
color: #b91c1c;
border-radius: 10px;
padding: 0.25rem 0.65rem;
cursor: pointer;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.empty-stage {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.35rem;
color: var(--muted);
position: relative;
}
.empty-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
filter: blur(8px);
}
2026-09-29 17:22:34 +03:30
.lightbox {
position: fixed;
inset: 0;
z-index: 60;
border: none;
background: rgba(15, 23, 42, 0.82);
display: grid;
place-items: center;
padding: 1.5rem;
}
.lightbox img {
max-width: min(960px, 100%);
max-height: 90vh;
border-radius: 12px;
object-fit: contain;
}
.lightbox-close {
position: absolute;
top: 1rem;
left: 1rem;
border: none;
border-radius: 999px;
width: 2.5rem;
height: 2.5rem;
background: rgba(255, 255, 255, 0.15);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
}
.modal {
position: fixed;
inset: 0;
z-index: 40;
border: none;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 1rem;
}
.modal-card {
background: var(--surface);
border-radius: 18px;
padding: 1.25rem;
width: min(400px, 100%);
display: grid;
gap: 0.75rem;
}
.modal-card.wide {
width: min(440px, 100%);
max-height: min(88vh, 640px);
overflow: auto;
}
.modal-card select,
.modal-card input[type='text'],
.modal-card input[type='search'],
.modal-card input:not([type]) {
width: 100%;
padding: 0.6rem 0.75rem;
border-radius: 12px;
border: 1px solid var(--line);
font: inherit;
box-sizing: border-box;
}
.picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.picker-head .hint {
margin: 0;
}
.picker-count {
font-size: 0.78rem;
font-weight: 700;
color: #0f766e;
background: rgba(13, 148, 136, 0.12);
padding: 0.2rem 0.55rem;
border-radius: 999px;
}
.channel-hint {
margin: 0;
padding: 0.55rem 0.7rem;
border-radius: 12px;
background: #fff7ed;
color: #9a3412;
font-size: 0.82rem;
line-height: 1.45;
}
.peer-search {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.7rem;
border-radius: 12px;
background: rgba(148, 163, 184, 0.12);
border: 1px solid rgba(148, 163, 184, 0.22);
color: var(--muted);
}
.peer-search input {
flex: 1;
min-width: 0;
border: none !important;
background: transparent !important;
padding: 0.15rem 0 !important;
outline: none;
font: inherit;
color: var(--text);
}
.picked-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.picked-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid rgba(13, 148, 136, 0.28);
background: rgba(13, 148, 136, 0.08);
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.45rem 0.2rem 0.25rem;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
max-width: 100%;
}
.picked-chip span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.peer-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 240px;
overflow: auto;
padding: 0.15rem;
margin: 0 -0.15rem;
}
.peer-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
width: 100%;
border: 1px solid transparent;
background: #fff;
border-radius: 14px;
padding: 0.45rem 0.55rem;
text-align: right;
cursor: pointer;
font: inherit;
color: var(--text);
transition: background 0.12s ease, border-color 0.12s ease;
}
.peer-row:hover {
background: rgba(13, 148, 136, 0.05);
}
.peer-row.on {
background: rgba(13, 148, 136, 0.1);
border-color: rgba(13, 148, 136, 0.35);
}
.peer-row-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.peer-row-meta strong {
font-size: 0.92rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.peer-row-meta small {
color: var(--muted);
font-size: 0.74rem;
}
.peer-tick {
width: 1.4rem;
height: 1.4rem;
border-radius: 999px;
border: 1.5px solid rgba(148, 163, 184, 0.55);
display: grid;
place-items: center;
color: #fff;
background: transparent;
flex-shrink: 0;
}
.peer-row.on .peer-tick {
background: #0d9488;
border-color: #0d9488;
}
.peer-empty {
margin: 0.75rem 0;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.type-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.type-chip {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.type-chip.on {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.45);
color: #4338ca;
}
.act-err {
margin: 0;
color: #b91c1c;
font-size: 0.85rem;
}
.activity-sheet {
width: min(400px, 100%);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
.modal-actions button {
border: 1px solid var(--line);
background: #fff;
padding: 0.5rem 0.9rem;
border-radius: 10px;
cursor: pointer;
font: inherit;
}
.modal-actions .primary {
background: #0d9488;
color: #fff;
border-color: #0d9488;
}
.modal-actions .primary:disabled,
.modal-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
.pad {
padding: 2rem;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
@media (max-width: 900px) {
.conv-page {
margin: -1rem -0.85rem calc(-1.25rem - var(--mobile-tabbar-h, 3.65rem));
padding: 0;
min-height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
display: flex;
flex-direction: column;
overflow: hidden;
background: #f8fafc;
}
.conv-page.chat-open {
background: #fff;
}
.conv-aurora,
.desktop-head {
display: none;
}
.mobile-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
background: #fff;
flex-shrink: 0;
}
.mobile-list-head[hidden] {
display: none !important;
}
.mobile-list-head h1 {
margin: 0;
font-size: 1.2rem;
font-weight: 800;
color: #0f766e;
}
.err-banner {
margin: 0.5rem 0.75rem;
border-radius: 12px;
}
.conv-shell {
flex: 1;
min-height: 0;
height: auto;
display: flex;
flex-direction: column;
gap: 0;
}
.conv-rail {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
background: #fff;
backdrop-filter: none;
padding: 0.5rem 0.35rem 0.75rem;
}
.conv-rail[hidden],
.conv-page.chat-open .conv-rail {
display: none !important;
}
.rail-actions {
padding: 0 0.5rem 0.35rem;
}
.rail-filters {
padding: 0 0.5rem 0.45rem;
}
.rail-tabs button.active {
background: rgba(13, 148, 136, 0.12);
box-shadow: none;
}
.room-list {
max-height: none;
}
.room-chip {
border-radius: 0;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.room-chip.active,
.room-chip:hover {
background: rgba(13, 148, 136, 0.06);
}
.conv-stage {
display: none;
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
background: #fff;
backdrop-filter: none;
}
.conv-stage.open {
display: flex;
}
.desktop-empty {
display: none;
}
.back-btn {
display: grid;
}
.stage-top {
padding: 0.55rem 0.65rem;
background: #fff;
position: sticky;
top: 0;
z-index: 2;
}
.pin-banner {
position: sticky;
top: 3.25rem;
z-index: 2;
background: #ecfdf5;
}
.mention-sheet {
position: absolute;
left: 0.5rem;
right: 0.5rem;
bottom: calc(100% + 0.35rem);
max-height: 42vh;
z-index: 5;
}
.composer {
position: relative;
}
.activity-sheet,
.modal-card.wide {
width: min(100%, 100vw - 1.5rem);
border-radius: 18px 18px 14px 14px;
}
.peer-list {
max-height: 38vh;
}
.msg-stream {
padding: 0.75rem 0.7rem 0.5rem;
}
.bubble {
max-width: min(300px, 82%);
}
.composer {
2026-09-29 21:06:04 +03:30
padding: 0.4rem 0.45rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
background: #f0f2f5;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05);
}
.tool-btn,
.send-btn {
2026-09-29 21:06:04 +03:30
width: 2.85rem;
height: 2.85rem;
min-width: 2.85rem;
}
.input-shell {
min-height: 2.85rem;
}
.composer-input {
2026-09-29 21:06:04 +03:30
padding: 0.6rem 0.3rem;
font-size: 1rem;
}
2026-09-29 21:06:04 +03:30
.emoji-panel {
grid-template-columns: repeat(8, minmax(0, 1fr));
max-height: 11rem;
}
}
</style>