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

4721 lines
133 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import { matchesSearch } from '@/lib/search';
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,
onMessageReactionEvent,
onRoomLiveEvent,
previewLabelForMessage,
setChatMutePrefs,
useOrgChat,
type ChatDelivery,
type ChatMessage,
type MessagePinEvent,
type MessageReactionAgg,
type MessageReactionEvent,
type RoomLiveEvent,
} from '@/lib/useOrgChat';
import {
crmPathFor,
insertCrmLink,
parseChatMessageText,
} from '@/lib/chatMessageFormat';
import { useAuthStore } from '@/stores/auth';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import {
brand,
type PaginatedResponse,
type PersonDto,
type PipelineBoardDto,
type TicketDto,
} from '@mento/shared';
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);
const showNewChannel = ref(false);
const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
const channelName = ref('');
const channelMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const peerPickerQuery = ref('');
const chatUserId = ref('');
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);
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);
const CHAT_EMOJIS = [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
];
const {
messages,
loadingMessages,
hasMoreMessages,
loadingOlderMessages,
status,
loadMessages,
loadOlderMessages,
sendTyping,
typingMap,
sendText,
sendFile,
editMessage,
deleteMessage,
deleteMessages,
forwardMessages,
pinChatMessage,
unpinChatMessage,
toggleMessageReaction,
sendRead,
ensureConnected,
setConversationsPageOpen,
refreshUnreadTotal,
clearActiveRoom,
} = useOrgChat();
const msgStreamEl = ref<HTMLElement | null>(null);
let stopRoomLive: (() => boolean) | undefined;
let stopPinLive: (() => boolean) | undefined;
let stopReactLive: (() => boolean) | undefined;
let mediaRecorder: MediaRecorder | null = null;
let recordChunks: Blob[] = [];
let recordTimer: ReturnType<typeof setInterval> | null = null;
let recordStream: MediaStream | null = null;
const selectMode = ref(false);
const selectedIds = ref<string[]>([]);
const showForward = ref(false);
const forwardBusy = ref(false);
const roomListQuery = ref('');
const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all');
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
const messageReactions = ref<MessageReactionAgg[]>([]);
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] as const;
const showCrmLink = ref(false);
const crmLinkKind = ref<'person' | 'deal' | 'ticket'>('person');
const crmLinkQuery = ref('');
const crmLinkResults = ref<Array<{ id: string; title: string }>>([]);
const crmLinkBusy = ref(false);
const showAiAssist = ref(false);
const aiChatContext = ref('');
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: 'یادداشت' },
];
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
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();
if (!q) return peers.value;
return peers.value.filter((p) => matchesSearch(q, [p.fullName]));
});
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(
(t) => Date.now() - t.at < 4000,
);
if (!list.length) return '';
const names = list
.map((t) => {
if (t.name) return t.name;
if (t.userId === '__someone__') return '';
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));
});
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();
return sortedRooms.value.filter((room) => {
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
if (!q) return true;
return matchesSearch(q, [roomTitle(room), room.lastMessagePreview]);
});
});
const mentionCandidates = computed(() => {
const state = mentionState.value;
if (!state) return [] as ConversationPeerDto[];
const q = state.query.trim();
return peers.value
.filter((p) => !q || matchesSearch(q, [p.fullName]))
.slice(0, 8);
});
const chatSearchHits = computed(() => {
const q = chatSearchQuery.value.trim();
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
return messages.value.filter((m) => {
if (m.deleted) return false;
if (m.type === 'text') return matchesSearch(q, [m.content]);
return matchesSearch(q, [previewLabelForMessage(m)]);
});
});
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) {
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;
}
function roomAvatarProps(room: ConversationRoomDto) {
if (room.kind !== 'dm') return { src: '' };
const peer = peers.value.find((p) => p.id === room.peerUserId);
return { userId: room.peerUserId, hasAvatar: peer?.hasAvatar };
}
function roomKindLabel(kind: ConversationRoomDto['kind']) {
if (kind === 'channel') return 'کانال';
if (kind === 'group') return 'گروه';
return 'خصوصی';
}
type TextSeg = ReturnType<typeof parseChatMessageText>[number];
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
return parseChatMessageText(content, {
mentionNames: peers.value.map((p) => p.fullName),
highlightQuery,
});
}
function reactionsForMessage(messageId: string) {
return messageReactions.value.filter((r) => r.messageId === messageId);
}
function syncMutePrefs() {
const prefs: Record<string, { allowMentions: boolean }> = {};
for (const r of rooms.value) {
if (r.muted) {
prefs[r.externalRoomId] = { allowMentions: r.muteAllowMentions !== false };
}
}
setChatMutePrefs(prefs, auth.user?.fullName || '');
}
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 roomTypingPreview(roomId: string): string {
void typingTick.value;
const list = (typingMap.value?.[roomId] || []).filter(
(t) => Date.now() - t.at < 4000,
);
if (!list.length) return '';
const names = list
.map((t) => {
if (t.name) return t.name;
if (t.userId === '__someone__') return '';
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]} در حال نوشتن…`;
return `${toFaDigits(names.length)} نفر در حال نوشتن…`;
}
function roomPreviewText(room: ConversationRoomDto) {
const typing = roomTypingPreview(room.externalRoomId);
if (typing) return typing;
const raw = (room.lastMessagePreview || '').trim();
if (!raw) return 'بدون پیام';
if (raw === 'وویس') return 'پیام صوتی';
return raw;
}
function closeMobileChat() {
clearActiveRoom();
activeRoom.value = null;
clearPendingAttaches();
cancelEdit();
cancelReply();
exitSelectMode();
emojiOpen.value = false;
msgMenu.value = null;
showForward.value = false;
closeChatSearch();
mentionState.value = null;
pinnedMessage.value = null;
closeActivityFromMsg();
if (recording.value) mediaRecorder?.stop();
}
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
const canSend = computed(
() => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value),
);
const showMicInsteadOfSend = computed(
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
);
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;
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;
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;
}
}
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());
}
function startEditMessage(msg: ChatMessage) {
if (msg.deleted || msg.type !== 'text' || msg.optimistic) return;
editingMessageId.value = msg.id;
replyTo.value = null;
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 : 'سنجاق گفتگو ناموفق بود.';
}
}
async function toggleRoomMuted() {
if (!activeRoom.value) return;
const room = activeRoom.value;
const nextMuted = !room.muted;
try {
const { data } = await api.post<{
muted: boolean;
muteAllowMentions: boolean;
}>('/conversations/rooms/mute', {
externalRoomId: room.externalRoomId,
muted: nextMuted,
allowMentions: true,
});
const updated: ConversationRoomDto = {
...room,
muted: data.muted,
muteAllowMentions: data.muteAllowMentions,
};
activeRoom.value = updated;
rooms.value = rooms.value.map((r) =>
r.externalRoomId === room.externalRoomId ? { ...r, ...updated } : r,
);
syncMutePrefs();
} catch (e) {
error.value = e instanceof Error ? e.message : 'تغییر اعلان گفتگو ناموفق بود.';
}
}
async function refreshReactions(roomId: string) {
try {
const { data } = await api.get<MessageReactionAgg[]>('/conversations/messages/reactions', {
params: { externalRoomId: roomId },
});
messageReactions.value = Array.isArray(data) ? data : [];
} catch {
messageReactions.value = [];
}
}
function applyReactionLive(event: MessageReactionEvent) {
if (event.fromSelf) return;
if (activeRoom.value?.externalRoomId !== event.roomId) return;
void refreshReactions(event.roomId);
}
async function reactToMessage(msg: ChatMessage, emoji: string) {
if (!activeRoom.value || msg.optimistic) return;
msgMenu.value = null;
try {
const list = await toggleMessageReaction(
activeRoom.value.externalRoomId,
msg.id,
emoji,
);
if (list) messageReactions.value = list;
else await refreshReactions(activeRoom.value.externalRoomId);
} catch (e) {
error.value = e instanceof Error ? e.message : 'واکنش ناموفق بود.';
}
}
function wrapDraftSelection(before: string, after = before) {
const el = draftInputEl.value;
const value = draft.value;
if (!el) {
draft.value = `${before}${value}${after}`;
return;
}
const start = el.selectionStart ?? value.length;
const end = el.selectionEnd ?? value.length;
const selected = value.slice(start, end) || 'متن';
const next = value.slice(0, start) + before + selected + after + value.slice(end);
draft.value = next;
nextTick(() => {
el.focus();
const pos = start + before.length + selected.length + after.length;
el.setSelectionRange(pos, pos);
});
}
function formatDraftBold() {
wrapDraftSelection('*', '*');
}
function formatDraftItalic() {
wrapDraftSelection('_', '_');
}
function formatDraftList() {
const el = draftInputEl.value;
const value = draft.value;
const start = el?.selectionStart ?? value.length;
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
const prefix = value.slice(lineStart, lineStart + 2) === '- ' ? '' : '- ';
draft.value = value.slice(0, lineStart) + prefix + value.slice(lineStart);
nextTick(() => {
el?.focus();
const pos = start + prefix.length;
el?.setSelectionRange(pos, pos);
});
}
async function searchCrmLinks() {
const q = crmLinkQuery.value.trim();
crmLinkBusy.value = true;
crmLinkResults.value = [];
try {
if (crmLinkKind.value === 'person') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: { q: q || undefined, limit: 20 },
});
crmLinkResults.value = (data.items || []).map((p) => ({
id: p.id,
title: p.fullName || 'شخص',
}));
} else if (crmLinkKind.value === 'ticket') {
const { data } = await api.get<PaginatedResponse<TicketDto>>('/tickets', {
params: { q: q || undefined, limit: 20 },
});
crmLinkResults.value = (data.items || []).map((t) => ({
id: t.id,
title: t.subject || `تیکت ${t.id.slice(0, 6)}`,
}));
} else {
const { data } = await api.get<PipelineBoardDto>('/pipelines/board');
const deals = (data.stages || []).flatMap((s) => s.deals || []);
crmLinkResults.value = deals
.filter(
(d) =>
!q.trim() ||
d.id.includes(q) ||
matchesSearch(q, [d.title, d.person?.fullName, d.company?.name]),
)
.slice(0, 20)
.map((d) => ({ id: d.id, title: d.title || 'معامله' }));
}
} catch {
crmLinkResults.value = [];
} finally {
crmLinkBusy.value = false;
}
}
function openCrmLinkPicker() {
showCrmLink.value = true;
crmLinkQuery.value = '';
crmLinkResults.value = [];
void searchCrmLinks();
}
function messageSenderLabel(msg: ChatMessage) {
if (isMineMessage(msg)) return 'شما';
return (
peers.value.find((p) => p.chatUserId === msg.senderId)?.fullName ||
peers.value.find((p) => p.id === msg.senderId)?.fullName ||
'همکار'
);
}
function buildChatContext(): string {
const room = activeRoom.value;
const title = room ? roomTitle(room) : 'گفتگو';
const recent = messages.value.slice(-20);
const lines = recent.map((m) => {
const who = messageSenderLabel(m);
if (m.deleted) return `${who}: [حذف‌شده]`;
if (m.type === 'text') {
return `${who}: ${(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 280)}`;
}
return `${who}: ${previewLabelForMessage(m)}`;
});
return `گفتگو: ${title}\n${lines.join('\n')}`.slice(0, 3500);
}
function openAiAssist() {
aiChatContext.value = buildChatContext();
showAiAssist.value = true;
emojiOpen.value = false;
}
function insertAiAnswer(text: string) {
const chunk = text.trim();
if (!chunk) return;
const el = draftInputEl.value;
const value = draft.value;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const pad =
start > 0 && value.slice(0, start).length && !/\s$/.test(value.slice(0, start))
? '\n'
: '';
draft.value = value.slice(0, start) + pad + chunk + value.slice(end);
showAiAssist.value = false;
nextTick(() => {
draftInputEl.value?.focus();
onDraftInput();
});
}
function insertCrmEntity(item: { id: string; title: string }) {
const chunk = insertCrmLink(crmLinkKind.value, item.id, item.title);
const el = draftInputEl.value;
const value = draft.value;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const pad = start > 0 && !/\s$/.test(value.slice(0, start)) ? ' ' : '';
draft.value = value.slice(0, start) + pad + chunk + ' ' + value.slice(end);
showCrmLink.value = false;
nextTick(() => draftInputEl.value?.focus());
}
function openCrmSeg(seg: TextSeg) {
if (seg.crm) {
void router.push(crmPathFor(seg.crm.kind, seg.crm.id));
return;
}
if (seg.link && /^https?:/i.test(seg.link)) {
window.open(seg.link, '_blank', 'noopener');
}
}
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;
}
}
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;
try {
await deleteMessage(activeRoom.value.externalRoomId, msg.id, {
forEveryone: mine,
});
if (editingMessageId.value === msg.id) cancelEdit();
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
}
}
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;
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
if (isMobileComposer()) return;
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
insertComposerNewline();
return;
}
if (event.shiftKey) return;
event.preventDefault();
void submitMessage();
}
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
if (msg.deleted || msg.optimistic) return;
if (selectMode.value) {
toggleSelect(msg);
return;
}
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 };
});
}
function menuMessage(): ChatMessage | null {
if (!msgMenu.value) return null;
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
}
function isMineMessage(msg: ChatMessage | null | undefined) {
if (!msg) return false;
return msg.senderId === chatUserId.value || Boolean(msg.optimistic);
}
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();
}
function classifyPending(file: File): PendingAttach['kind'] {
return classifyChatFile(file);
}
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 });
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;
let unreadBump = 0;
if (!event.fromSelf && !isActive) {
const muted = Boolean(room.muted);
const allowMentions = room.muteAllowMentions !== false;
const myName = (auth.user?.fullName || '').trim();
const mentionsMe =
Boolean(myName) && Boolean(event.content) && event.content!.includes(`@${myName}`);
if (!muted || (allowMentions && mentionsMe)) unreadBump = 1;
}
const next: ConversationRoomDto = {
...room,
lastMessagePreview: event.preview,
lastMessageAt: event.at,
unreadCount: (room.unreadCount || 0) + unreadBump,
};
if (isActive) next.unreadCount = 0;
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;
syncMutePrefs();
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 {
const accessRes = await api.get<ConversationAccessDto>('/conversations/access');
access.value = accessRes.data;
if (!accessRes.data.canUse) {
error.value = 'به گفتگو دسترسی ندارید.';
return;
}
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;
syncMutePrefs();
await refreshUnreadTotal();
} 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 = 'ماژول گفتگو برای این محیط فعال نیست.';
} 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);
await refreshReactions(room.externalRoomId);
sendRead(room.externalRoomId);
void refreshUnreadTotal();
}
async function submitMessage() {
if (!activeRoom.value) return;
if (!canPostInActive.value) {
error.value = 'فقط مدیران کانال می‌توانند پیام بفرستند.';
return;
}
if (sending.value || recording.value) return;
const text = draft.value.trim();
const attaches = [...pendingAttaches.value];
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;
}
if (!text && !attaches.length) return;
sending.value = true;
error.value = '';
const roomId = activeRoom.value.externalRoomId;
const replyId = replyTo.value?.id ?? null;
draft.value = '';
pendingAttaches.value = [];
emojiOpen.value = false;
replyTo.value = null;
try {
if (text) {
await sendText(roomId, text, replyId);
}
for (const item of attaches) {
try {
await sendFile(roomId, item.file, replyId);
} 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)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (e.response?.status === 403) {
error.value = 'اجازه ساخت گروه را ندارید.';
} else if (Array.isArray(msg)) {
error.value = msg.join('، ');
} else if (typeof msg === 'string' && msg.trim()) {
error.value = msg;
} else {
error.value = 'ساخت گروه ناموفق بود.';
}
} else {
error.value = 'ساخت گروه ناموفق بود.';
}
}
}
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];
}
}
function toggleChannelMember(id: string) {
if (channelMemberIds.value.includes(id)) {
channelMemberIds.value = channelMemberIds.value.filter((x) => x !== id);
} else {
channelMemberIds.value = [...channelMemberIds.value, id];
}
}
useEscClose(() => showNewDm.value, () => { showNewDm.value = false; });
useEscClose(() => showNewGroup.value, () => { showNewGroup.value = false; });
useEscClose(() => showNewChannel.value, () => { showNewChannel.value = false; });
useEscClose(() => activityFromMsg.value, () => closeActivityFromMsg());
useEscClose(() => showCrmLink.value, () => { showCrmLink.value = false; });
useEscClose(() => showAiAssist.value, () => { showAiAssist.value = false; });
useEscClose(() => showMembers.value, () => { showMembers.value = false; });
useEscClose(() => showForward.value, () => { showForward.value = false; });
useEscClose(() => lightboxUrl.value, () => { lightboxUrl.value = ''; });
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 = '';
clearPendingAttaches();
exitSelectMode();
cancelEdit();
cancelReply();
emojiOpen.value = false;
msgMenu.value = null;
showForward.value = false;
closeChatSearch();
mentionState.value = null;
if (recording.value) {
mediaRecorder?.stop();
}
});
watch(
() => messages.value.length,
() => {
if (chatSearchOpen.value) return;
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));
});
let typingUiTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
setConversationsPageOpen(true);
stopRoomLive = onRoomLiveEvent(applyRoomLive);
stopPinLive = onMessagePinEvent(applyMessagePin);
stopReactLive = onMessageReactionEvent(applyReactionLive);
void bootstrap();
typingUiTimer = setInterval(() => {
typingTick.value += 1;
}, 1000);
});
onUnmounted(() => {
setConversationsPageOpen(false);
stopRoomLive?.();
stopPinLive?.();
stopReactLive?.();
if (typingUiTimer) clearInterval(typingUiTimer);
clearActiveRoom();
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>
<button
v-if="canCreateChannel"
type="button"
class="fab ghost"
@click="openNewChannel"
>
کانال
</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>
<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, muted: room.muted }"
@click="openRoom(room)"
>
<span class="avatar-ring">
<UserAvatar v-bind="roomAvatarProps(room)" :name="roomTitle(room)" :size="40" />
</span>
<span class="room-meta">
<strong>
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
<UiIcon v-if="room.muted" name="eye-off" :size="12" class="room-mute-ic" />
{{ roomTitle(room) }}
</strong>
<small :class="{ 'room-typing': Boolean(roomTypingPreview(room.externalRoomId)) }">{{
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">
<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>
<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 v-bind="roomAvatarProps(activeRoom)" :name="roomTitle(activeRoom)" :size="36" />
</span>
<div class="stage-titles">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag" :data-kind="activeRoom.kind">{{
roomKindLabel(activeRoom.kind)
}}</span>
</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.muted }"
:title="
activeRoom.muted
? 'روشن کردن اعلان (منشن همچنان مستثنی است)'
: 'بی‌صدا کردن گفتگو (منشن مستثنی است)'
"
@click="toggleRoomMuted"
>
<UiIcon :name="activeRoom.muted ? 'eye-off' : 'bell'" :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>
</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"
:class="{
mine: msg.senderId === chatUserId || msg.optimistic,
selected: isSelected(msg.id),
'search-hit': activeSearchHitId === msg.id,
}"
@contextmenu="openMsgMenu(msg, $event)"
@click="selectMode ? toggleSelect(msg) : undefined"
>
<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>
<div
class="bubble"
:class="{
media: msg.type !== 'text',
}"
@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>
<template v-if="msg.type === 'image'">
<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>
<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>
<button
v-else-if="seg.crm || seg.link"
type="button"
class="msg-link"
:class="{ crm: Boolean(seg.crm) }"
@click.stop="openCrmSeg(seg)"
>
<strong v-if="seg.bold"><em v-if="seg.italic">{{ seg.text }}</em><template v-else>{{ seg.text }}</template></strong>
<em v-else-if="seg.italic">{{ seg.text }}</em>
<template v-else>{{ seg.text }}</template>
</button>
<strong v-else-if="seg.bold">{{ seg.text }}</strong>
<em v-else-if="seg.italic">{{ seg.text }}</em>
<template v-else>{{ seg.text }}</template>
</template>
</p>
<div v-if="reactionsForMessage(msg.id).length" class="react-row">
<button
v-for="r in reactionsForMessage(msg.id)"
:key="`${r.messageId}-${r.emoji}`"
type="button"
class="react-chip"
:class="{ me: r.me }"
@click.stop="reactToMessage(msg, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span>{{ toFaDigits(r.count) }}</span>
</button>
</div>
<div class="bubble-meta">
<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>
<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" :user-id="peer.id" :has-avatar="peer.hasAvatar" :size="28" />
<span>{{ peer.fullName }}</span>
</button>
</div>
<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>
<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="fmt-bar">
<button type="button" class="fmt-btn fmt-b" title="پررنگ (*متن*)" @click="formatDraftBold">
B
</button>
<button type="button" class="fmt-btn fmt-i" title="ایتالیک (_متن_)" @click="formatDraftItalic">
I
</button>
<button type="button" class="fmt-btn" title="لیست" @click="formatDraftList">
<UiIcon name="list" :size="14" />
</button>
<button type="button" class="fmt-btn" title="لینک CRM" @click="openCrmLinkPicker">
<UiIcon name="briefcase" :size="14" />
</button>
<button
type="button"
class="fmt-btn fmt-ai"
:title="brand.askLabelFa"
@click="openAiAssist"
>
<UiIcon name="sparkle" :size="14" />
</button>
</div>
<div class="composer-row">
<input
ref="fileInputEl"
type="file"
class="sr-only"
multiple
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.txt,.csv"
@change="onFilePicked"
/>
<button
v-if="showMicInsteadOfSend"
type="button"
class="tool-btn mic-btn"
:class="{ on: recording }"
title="ضبط پیام صوتی"
:disabled="sending"
@click="toggleVoiceRecord"
>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
</button>
<button
v-else
type="submit"
class="send-btn"
:disabled="sending || recording || !canSend"
>
<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"
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>
</div>
</form>
<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` }
"
>
<div class="react-quick">
<button
v-for="em in QUICK_REACTIONS"
:key="em"
type="button"
class="react-quick-btn"
@click="reactToMessage(menuMessage()!, em)"
>
{{ em }}
</button>
</div>
<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
>
<div class="modal-card wide">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewDm = false))()">
<UiIcon name="x" :size="18" />
</button>
<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" :user-id="p.id" :has-avatar="p.hasAvatar" :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
>
<div class="modal-card wide member-picker-modal">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewGroup = false))()">
<UiIcon name="x" :size="18" />
</button>
<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" :user-id="p.id" :has-avatar="p.hasAvatar" :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" :user-id="p.id" :has-avatar="p.hasAvatar" :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>
<dialog
v-if="showNewChannel"
class="modal"
open
>
<div class="modal-card wide member-picker-modal">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewChannel = false))()">
<UiIcon name="x" :size="18" />
</button>
<h3>کانال جدید</h3>
<p class="hint channel-hint">
کانال یک‌طرفه است؛ فقط شما و مدیران سازمان می‌توانند پیام بفرستند.
</p>
<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" :user-id="p.id" :has-avatar="p.hasAvatar" :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" :user-id="p.id" :has-avatar="p.hasAvatar" :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>
</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>
</div>
</div>
</dialog>
<dialog
v-if="showActivityFromMsg"
class="modal"
open
>
<div class="modal-card activity-sheet">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(closeActivityFromMsg)()">
<UiIcon name="x" :size="18" />
</button>
<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="showCrmLink"
class="modal"
open
>
<div class="modal-card wide">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showCrmLink = false))()">
<UiIcon name="x" :size="18" />
</button>
<h3>لینک CRM</h3>
<p class="hint">شخص، معامله یا تیکت را انتخاب کنید تا در پیام درج شود.</p>
<div class="crm-kind-tabs" role="tablist">
<button
type="button"
:class="{ active: crmLinkKind === 'person' }"
@click="crmLinkKind = 'person'; searchCrmLinks()"
>
شخص
</button>
<button
type="button"
:class="{ active: crmLinkKind === 'deal' }"
@click="crmLinkKind = 'deal'; searchCrmLinks()"
>
معامله
</button>
<button
type="button"
:class="{ active: crmLinkKind === 'ticket' }"
@click="crmLinkKind = 'ticket'; searchCrmLinks()"
>
تیکت
</button>
</div>
<label class="peer-search">
<UiIcon name="search" :size="16" />
<input
v-model="crmLinkQuery"
type="search"
placeholder="جستجو…"
autocomplete="off"
@input="searchCrmLinks"
/>
</label>
<div class="peer-list">
<div v-if="crmLinkBusy" class="muted center pad">در حال جستجو…</div>
<button
v-for="item in crmLinkResults"
:key="item.id"
type="button"
class="peer-row"
@click="insertCrmEntity(item)"
>
<span class="peer-row-meta">
<strong>{{ item.title }}</strong>
<small>{{ item.id.slice(0, 8) }}…</small>
</span>
</button>
<p v-if="!crmLinkBusy && !crmLinkResults.length" class="peer-empty">موردی پیدا نشد</p>
</div>
<div class="modal-actions">
<button type="button" @click="showCrmLink = false">بستن</button>
</div>
</div>
</dialog>
<dialog
v-if="showAiAssist"
class="modal"
open
>
<div class="modal-card wide ai-assist-sheet">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showAiAssist = false))()">
<UiIcon name="x" :size="18" />
</button>
<h3>{{ brand.askLabelFa }}</h3>
<p class="hint">با توجه به CRM و چند پیام اخیر این گفتگو پاسخ می‌دهد؛ می‌توانید پاسخ را در پیش‌نویس درج کنید.</p>
<AskMentoPanel
:chat-context="aiChatContext"
allow-insert
:placeholder="`${brand.askLabelFa} درباره این گفتگو…`"
@insert-answer="insertAiAnswer"
/>
<div class="modal-actions">
<button type="button" @click="showAiAssist = false">بستن</button>
</div>
</div>
</dialog>
<dialog
v-if="showMembers"
class="modal"
open
>
<div class="modal-card wide members-sheet member-picker-modal">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showMembers = false))()">
<UiIcon name="x" :size="18" />
</button>
<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" :user-id="m.userId" :has-avatar="m.hasAvatar" :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>
<dialog
v-if="showForward"
class="modal"
open
>
<div class="modal-card">
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showForward = false))()">
<UiIcon name="x" :size="18" />
</button>
<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 v-bind="roomAvatarProps(room)" :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>
<dialog v-if="lightboxUrl" class="lightbox" open>
<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;
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;
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;
padding: 0.35rem 0.15rem;
font: inherit;
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-mute-ic {
color: #64748b;
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;
}
.room-meta small.room-typing {
color: #0f766e;
font-weight: 600;
}
.msg-text .msg-link {
border: none;
background: transparent;
color: #0f766e;
text-decoration: underline;
text-underline-offset: 2px;
font: inherit;
padding: 0;
cursor: pointer;
}
.msg-text .msg-link.crm {
color: #0369a1;
font-weight: 600;
text-decoration: none;
background: rgba(3, 105, 161, 0.08);
border-radius: 6px;
padding: 0 0.25rem;
}
.msg-text strong {
font-weight: 700;
}
.react-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.35rem;
}
.react-chip {
border: 1px solid rgba(15, 118, 110, 0.2);
background: rgba(13, 148, 136, 0.08);
border-radius: 999px;
padding: 0.1rem 0.4rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
display: inline-flex;
gap: 0.2rem;
align-items: center;
}
.react-chip.me {
border-color: #0d9488;
background: rgba(13, 148, 136, 0.18);
}
.react-quick {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
gap: 0.15rem;
width: 100%;
box-sizing: border-box;
padding: 0.25rem 0.2rem 0.35rem;
border-bottom: 1px solid var(--line);
margin: 0 0 0.15rem;
overflow: hidden;
}
.react-quick-btn {
flex: 1 1 0;
min-width: 0;
max-width: 2.4rem;
height: 2.2rem;
border: none !important;
background: transparent !important;
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
padding: 0 !important;
border-radius: 8px;
display: grid !important;
place-items: center;
color: inherit !important;
gap: 0 !important;
}
.react-quick-btn:hover,
.react-quick-btn:active {
background: rgba(13, 148, 136, 0.12) !important;
}
.fmt-bar {
display: flex;
gap: 0.3rem;
padding: 0 0.65rem 0.25rem;
}
.fmt-btn {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
width: 1.85rem;
height: 1.85rem;
display: grid;
place-items: center;
cursor: pointer;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
}
.fmt-btn.fmt-b {
font-weight: 800;
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
}
.fmt-btn.fmt-i {
font-style: italic;
font-family: Georgia, 'Times New Roman', serif;
font-weight: 600;
font-size: 0.95rem;
}
.fmt-btn.fmt-ai {
color: #0f766e;
}
.msg-text em {
font-style: italic;
}
.crm-kind-tabs {
display: flex;
gap: 0.35rem;
margin-bottom: 0.65rem;
}
.crm-kind-tabs button {
flex: 1;
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 0.45rem;
font: inherit;
cursor: pointer;
}
.crm-kind-tabs button.active {
background: #0d9488;
border-color: #0d9488;
color: #fff;
}
.select-act.on {
color: #0d9488;
}
.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;
}
.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;
}
.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);
}
.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;
}
.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;
}
.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;
align-items: flex-end;
gap: 0.35rem;
}
.msg-row.mine {
justify-content: flex-end;
}
.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;
}
.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;
}
.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;
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;
flex-direction: column;
gap: 0.4rem;
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: #f0f2f5;
flex-shrink: 0;
position: relative;
}
.composer-row {
display: flex;
gap: 0.35rem;
align-items: flex-end;
min-width: 0;
}
.input-shell {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: flex-end;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 22px;
padding: 0.35rem 0.75rem;
min-height: 2.75rem;
}
.composer-input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
border-radius: 18px;
padding: 0.35rem 0;
font: inherit;
font-size: 0.95rem;
background: transparent;
resize: none;
max-height: 7.5rem;
line-height: 1.45;
field-sizing: content;
}
.emoji-toggle {
flex: 0 0 auto;
border: none;
background: #fff;
border: 1px solid #e2e8f0;
width: 2.75rem;
height: 2.75rem;
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);
}
.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;
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;
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
display: grid;
place-items: center;
cursor: pointer;
}
.tool-btn {
border: none;
background: transparent;
color: #64748b;
}
.tool-btn.attach-btn,
.tool-btn.mic-btn {
background: #fff;
border: 1px solid #e2e8f0;
color: #334155;
}
.tool-btn.on {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.tool-btn:disabled,
.send-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.send-btn {
border: none;
background: #0d9488;
color: #fff;
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);
}
.msg-menu {
position: fixed;
z-index: 1210;
min-width: 11rem;
max-width: min(18rem, calc(100vw - 1.5rem));
width: max-content;
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);
overflow: hidden;
box-sizing: border-box;
}
.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;
}
.msg-menu > button {
display: flex;
align-items: center;
gap: 0.45rem;
border: none;
background: transparent;
padding: 0.65rem 0.75rem;
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;
}
.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);
}
.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-x {
position: absolute;
top: 0.9rem;
left: 0.9rem;
z-index: 2;
width: 34px;
height: 34px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.modal-x:hover { background: var(--surface-subtle); color: var(--ink); }
.modal-card > h3 { padding-inline-end: 2.6rem; }
.modal-card {
position: relative;
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, 720px);
overflow: auto;
}
.modal-card.member-picker-modal {
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow: hidden;
min-height: min(70vh, 560px);
}
.modal-card.member-picker-modal > .peer-list,
.modal-card.member-picker-modal > .members-list {
flex: 1 1 auto;
min-height: 220px;
max-height: none;
}
.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: min(48vh, 360px);
overflow: auto;
overscroll-behavior: contain;
padding: 0.15rem;
margin: 0 -0.15rem;
-webkit-overflow-scrolling: touch;
}
.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 {
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 {
width: 2.85rem;
height: 2.85rem;
min-width: 2.85rem;
}
.input-shell {
min-height: 2.85rem;
}
.composer-input {
padding: 0.6rem 0.3rem;
font-size: 1rem;
}
.emoji-panel {
grid-template-columns: repeat(8, minmax(0, 1fr));
max-height: 11rem;
}
}
</style>