2026-09-29 10:34:01 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-29 16:22:37 +03:30
|
|
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
ConversationAccessDto,
|
|
|
|
|
|
ConversationPeerDto,
|
|
|
|
|
|
ConversationRoomDto,
|
|
|
|
|
|
} from '@mento/shared';
|
|
|
|
|
|
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
2026-09-29 16:22:37 +03:30
|
|
|
|
import {
|
2026-09-29 20:36:15 +03:30
|
|
|
|
classifyChatFile,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
onRoomLiveEvent,
|
|
|
|
|
|
useOrgChat,
|
|
|
|
|
|
type ChatDelivery,
|
2026-09-29 17:22:34 +03:30
|
|
|
|
type ChatMessage,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
type RoomLiveEvent,
|
|
|
|
|
|
} from '@/lib/useOrgChat';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
|
|
|
|
import UserAvatar from '@/components/UserAvatar.vue';
|
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
|
type PendingAttach = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
file: File;
|
|
|
|
|
|
kind: 'image' | 'voice' | 'video' | 'file';
|
|
|
|
|
|
previewUrl: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
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 groupName = ref('');
|
|
|
|
|
|
const groupMemberIds = ref<string[]>([]);
|
|
|
|
|
|
const dmPeerId = ref('');
|
2026-09-29 16:22:37 +03:30
|
|
|
|
const chatUserId = ref('');
|
2026-09-29 17:22:34 +03:30
|
|
|
|
const pendingAttaches = ref<PendingAttach[]>([]);
|
|
|
|
|
|
const sending = ref(false);
|
|
|
|
|
|
const recording = ref(false);
|
|
|
|
|
|
const recordSecs = ref(0);
|
|
|
|
|
|
const lightboxUrl = ref('');
|
|
|
|
|
|
const fileInputEl = ref<HTMLInputElement | null>(null);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
|
const {
|
|
|
|
|
|
messages,
|
|
|
|
|
|
loadingMessages,
|
|
|
|
|
|
status,
|
|
|
|
|
|
loadMessages,
|
|
|
|
|
|
sendText,
|
2026-09-29 17:22:34 +03:30
|
|
|
|
sendFile,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
sendRead,
|
|
|
|
|
|
ensureConnected,
|
|
|
|
|
|
setConversationsPageOpen,
|
|
|
|
|
|
refreshUnreadTotal,
|
|
|
|
|
|
clearActiveRoom,
|
|
|
|
|
|
} = useOrgChat();
|
2026-09-29 11:56:32 +03:30
|
|
|
|
const msgStreamEl = ref<HTMLElement | null>(null);
|
2026-09-29 16:22:37 +03:30
|
|
|
|
let stopRoomLive: (() => boolean) | undefined;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
let mediaRecorder: MediaRecorder | null = null;
|
|
|
|
|
|
let recordChunks: Blob[] = [];
|
|
|
|
|
|
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
let recordStream: MediaStream | null = null;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
|
|
|
|
|
|
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
|
async function scrollToBottom() {
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
const el = msgStreamEl.value;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
el.scrollTop = el.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const sortedRooms = computed(() =>
|
|
|
|
|
|
[...rooms.value].sort((a, b) => {
|
|
|
|
|
|
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
|
|
|
|
|
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
|
|
|
|
|
return tb - ta;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function roomTitle(room: ConversationRoomDto) {
|
|
|
|
|
|
if (room.kind === 'group') return room.name;
|
|
|
|
|
|
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
|
|
|
|
|
return peer?.fullName ?? room.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatListTime(iso: string | null) {
|
|
|
|
|
|
if (!iso) return '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
return formatClockFa(new Date(iso));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
|
function deliveryLabel(delivery?: ChatDelivery) {
|
|
|
|
|
|
if (delivery === 'read') return '✓✓';
|
|
|
|
|
|
if (delivery === 'sent') return '✓';
|
|
|
|
|
|
return '…';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
|
function roomPreviewText(room: ConversationRoomDto) {
|
|
|
|
|
|
const raw = (room.lastMessagePreview || '').trim();
|
|
|
|
|
|
if (!raw) return 'بدون پیام';
|
|
|
|
|
|
if (raw === 'وویس') return 'پیام صوتی';
|
|
|
|
|
|
return raw;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMobileChat() {
|
|
|
|
|
|
clearActiveRoom();
|
|
|
|
|
|
activeRoom.value = null;
|
|
|
|
|
|
clearPendingAttaches();
|
|
|
|
|
|
if (recording.value) mediaRecorder?.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
|
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
|
function classifyPending(file: File): PendingAttach['kind'] {
|
2026-09-29 20:36:15 +03:30
|
|
|
|
return classifyChatFile(file);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearPendingAttaches() {
|
|
|
|
|
|
for (const item of pendingAttaches.value) {
|
|
|
|
|
|
if (item.previewUrl.startsWith('blob:')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
URL.revokeObjectURL(item.previewUrl);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingAttaches.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removePending(id: string) {
|
|
|
|
|
|
const item = pendingAttaches.value.find((a) => a.id === id);
|
|
|
|
|
|
if (item?.previewUrl.startsWith('blob:')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
URL.revokeObjectURL(item.previewUrl);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingAttaches.value = pendingAttaches.value.filter((a) => a.id !== id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addFiles(files: FileList | File[]) {
|
|
|
|
|
|
const list = Array.from(files);
|
|
|
|
|
|
for (const file of list) {
|
|
|
|
|
|
pendingAttaches.value = [
|
|
|
|
|
|
...pendingAttaches.value,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: crypto.randomUUID(),
|
|
|
|
|
|
file,
|
|
|
|
|
|
kind: classifyPending(file),
|
|
|
|
|
|
previewUrl: URL.createObjectURL(file),
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onFilePicked(event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement;
|
|
|
|
|
|
if (input.files?.length) addFiles(input.files);
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mediaSrc(msg: ChatMessage) {
|
|
|
|
|
|
return msg.localPreviewUrl || msg.mediaUrl || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopRecordTimer() {
|
|
|
|
|
|
if (recordTimer) {
|
|
|
|
|
|
clearInterval(recordTimer);
|
|
|
|
|
|
recordTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopMicTracks() {
|
|
|
|
|
|
recordStream?.getTracks().forEach((t) => t.stop());
|
|
|
|
|
|
recordStream = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleVoiceRecord() {
|
|
|
|
|
|
if (recording.value) {
|
|
|
|
|
|
mediaRecorder?.stop();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
|
recordChunks = [];
|
|
|
|
|
|
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
|
|
|
|
? 'audio/webm;codecs=opus'
|
|
|
|
|
|
: MediaRecorder.isTypeSupported('audio/webm')
|
|
|
|
|
|
? 'audio/webm'
|
|
|
|
|
|
: '';
|
|
|
|
|
|
mediaRecorder = mime
|
|
|
|
|
|
? new MediaRecorder(recordStream, { mimeType: mime })
|
|
|
|
|
|
: new MediaRecorder(recordStream);
|
|
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
|
|
|
|
if (e.data.size) recordChunks.push(e.data);
|
|
|
|
|
|
};
|
|
|
|
|
|
mediaRecorder.onstop = () => {
|
|
|
|
|
|
stopRecordTimer();
|
|
|
|
|
|
recording.value = false;
|
|
|
|
|
|
const type = mediaRecorder?.mimeType || 'audio/webm';
|
|
|
|
|
|
stopMicTracks();
|
|
|
|
|
|
mediaRecorder = null;
|
|
|
|
|
|
if (!recordChunks.length) return;
|
|
|
|
|
|
const blob = new Blob(recordChunks, { type });
|
|
|
|
|
|
recordChunks = [];
|
|
|
|
|
|
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm';
|
2026-09-29 20:36:15 +03:30
|
|
|
|
// Force audio/* so classify never treats MediaRecorder output as video
|
|
|
|
|
|
const audioMime = type.startsWith('audio/')
|
|
|
|
|
|
? type.split(';')[0]
|
|
|
|
|
|
: ext === 'ogg'
|
|
|
|
|
|
? 'audio/ogg'
|
|
|
|
|
|
: ext === 'm4a'
|
|
|
|
|
|
? 'audio/mp4'
|
|
|
|
|
|
: 'audio/webm';
|
|
|
|
|
|
const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: audioMime });
|
2026-09-29 17:22:34 +03:30
|
|
|
|
addFiles([file]);
|
|
|
|
|
|
recordSecs.value = 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
mediaRecorder.start();
|
|
|
|
|
|
recording.value = true;
|
|
|
|
|
|
recordSecs.value = 0;
|
|
|
|
|
|
recordTimer = setInterval(() => {
|
|
|
|
|
|
recordSecs.value += 1;
|
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'دسترسی به میکروفون ممکن نشد.';
|
|
|
|
|
|
stopMicTracks();
|
|
|
|
|
|
recording.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordLabel() {
|
|
|
|
|
|
const m = Math.floor(recordSecs.value / 60);
|
|
|
|
|
|
const s = recordSecs.value % 60;
|
|
|
|
|
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
|
function applyRoomLive(event: RoomLiveEvent) {
|
|
|
|
|
|
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
|
|
|
|
|
|
if (idx < 0) {
|
|
|
|
|
|
void refreshRoomsSoft();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const room = rooms.value[idx];
|
|
|
|
|
|
const isActive = activeRoom.value?.externalRoomId === event.roomId;
|
|
|
|
|
|
const next: ConversationRoomDto = {
|
|
|
|
|
|
...room,
|
|
|
|
|
|
lastMessagePreview: event.preview,
|
|
|
|
|
|
lastMessageAt: event.at,
|
|
|
|
|
|
unreadCount: event.fromSelf || isActive ? 0 : (room.unreadCount || 0) + 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
const nextRooms = [...rooms.value];
|
|
|
|
|
|
nextRooms[idx] = next;
|
|
|
|
|
|
rooms.value = nextRooms;
|
|
|
|
|
|
if (isActive && activeRoom.value) {
|
|
|
|
|
|
activeRoom.value = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshRoomsSoft() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
|
|
|
|
|
|
rooms.value = data;
|
|
|
|
|
|
await refreshUnreadTotal();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// keep previous
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
async function bootstrap() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-29 15:27:14 +03:30
|
|
|
|
const accessRes = await api.get<ConversationAccessDto>('/conversations/access');
|
2026-09-29 10:34:01 +03:30
|
|
|
|
access.value = accessRes.data;
|
|
|
|
|
|
if (!accessRes.data.canUse) {
|
2026-09-29 15:27:14 +03:30
|
|
|
|
error.value = 'به گفتگو دسترسی ندارید.';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-29 15:27:14 +03:30
|
|
|
|
|
|
|
|
|
|
const [roomsOutcome, peersOutcome, sessionOutcome] = await Promise.allSettled([
|
|
|
|
|
|
api.get<ConversationRoomDto[]>('/conversations/rooms'),
|
|
|
|
|
|
api.get<ConversationPeerDto[]>('/conversations/peers'),
|
|
|
|
|
|
api.get<{ chatUserId: string }>('/conversations/session'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
if (peersOutcome.status === 'fulfilled') {
|
|
|
|
|
|
peers.value = peersOutcome.value.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sessionOutcome.status === 'fulfilled') {
|
|
|
|
|
|
chatUserId.value = sessionOutcome.value.data.chatUserId;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (roomsOutcome.status === 'fulfilled') {
|
|
|
|
|
|
rooms.value = roomsOutcome.value.data;
|
2026-09-29 16:22:37 +03:30
|
|
|
|
await refreshUnreadTotal();
|
2026-09-29 15:27:14 +03:30
|
|
|
|
} else {
|
|
|
|
|
|
const e = roomsOutcome.reason;
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
error.value = Array.isArray(msg)
|
|
|
|
|
|
? msg.join('، ')
|
|
|
|
|
|
: msg ||
|
|
|
|
|
|
(e.response?.status === 503 || e.response?.status === 502
|
|
|
|
|
|
? 'اتصال به سرویس چت برقرار نشد. Host و API Key را در سوپرادمین بررسی کنید.'
|
|
|
|
|
|
: 'بارگذاری لیست گفتگو ناموفق بود.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری لیست گفتگو ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-29 16:22:37 +03:30
|
|
|
|
|
|
|
|
|
|
await ensureConnected();
|
2026-09-29 10:34:01 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
|
|
|
|
|
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
|
2026-09-29 15:27:14 +03:30
|
|
|
|
} else if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
error.value = Array.isArray(msg)
|
|
|
|
|
|
? msg.join('، ')
|
|
|
|
|
|
: msg || 'بارگذاری گفتگو ناموفق بود.';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری گفتگو ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openRoom(room: ConversationRoomDto) {
|
2026-09-29 16:22:37 +03:30
|
|
|
|
const cleared = { ...room, unreadCount: 0 };
|
|
|
|
|
|
activeRoom.value = cleared;
|
|
|
|
|
|
rooms.value = rooms.value.map((r) =>
|
|
|
|
|
|
r.externalRoomId === room.externalRoomId ? cleared : r,
|
|
|
|
|
|
);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
await loadMessages(room.externalRoomId);
|
|
|
|
|
|
sendRead(room.externalRoomId);
|
2026-09-29 16:22:37 +03:30
|
|
|
|
void refreshUnreadTotal();
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitMessage() {
|
2026-09-29 17:22:34 +03:30
|
|
|
|
if (!activeRoom.value) return;
|
|
|
|
|
|
if (sending.value || recording.value) return;
|
|
|
|
|
|
const text = draft.value.trim();
|
|
|
|
|
|
const attaches = [...pendingAttaches.value];
|
|
|
|
|
|
if (!text && !attaches.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
sending.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
const roomId = activeRoom.value.externalRoomId;
|
|
|
|
|
|
draft.value = '';
|
|
|
|
|
|
pendingAttaches.value = [];
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
try {
|
2026-09-29 17:22:34 +03:30
|
|
|
|
if (text) {
|
|
|
|
|
|
await sendText(roomId, text);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const item of attaches) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await sendFile(roomId, item.file);
|
|
|
|
|
|
} 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;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startDm() {
|
|
|
|
|
|
if (!dmPeerId.value) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
|
|
|
|
|
|
peerUserId: dmPeerId.value,
|
|
|
|
|
|
});
|
|
|
|
|
|
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
|
|
|
|
|
|
showNewDm.value = false;
|
|
|
|
|
|
dmPeerId.value = '';
|
|
|
|
|
|
await openRoom(data);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'شروع گفتگوی جدید ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function startGroup() {
|
|
|
|
|
|
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
|
|
|
|
|
|
name: groupName.value.trim(),
|
|
|
|
|
|
memberUserIds: groupMemberIds.value,
|
|
|
|
|
|
});
|
|
|
|
|
|
rooms.value = [data, ...rooms.value];
|
|
|
|
|
|
showNewGroup.value = false;
|
|
|
|
|
|
groupName.value = '';
|
|
|
|
|
|
groupMemberIds.value = [];
|
|
|
|
|
|
await openRoom(data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
|
|
|
|
|
error.value = 'اجازه ساخت گروه را ندارید.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'ساخت گروه ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleGroupMember(id: string) {
|
|
|
|
|
|
if (groupMemberIds.value.includes(id)) {
|
|
|
|
|
|
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
groupMemberIds.value = [...groupMemberIds.value, id];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(activeRoom, () => {
|
|
|
|
|
|
draft.value = '';
|
2026-09-29 17:22:34 +03:30
|
|
|
|
clearPendingAttaches();
|
|
|
|
|
|
if (recording.value) {
|
|
|
|
|
|
mediaRecorder?.stop();
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
|
watch(
|
|
|
|
|
|
() => messages.value.length,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
void scrollToBottom();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch(loadingMessages, (v) => {
|
|
|
|
|
|
if (!v) void scrollToBottom();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
onMounted(() => {
|
2026-09-29 16:22:37 +03:30
|
|
|
|
setConversationsPageOpen(true);
|
|
|
|
|
|
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
void bootstrap();
|
|
|
|
|
|
});
|
2026-09-29 16:22:37 +03:30
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
setConversationsPageOpen(false);
|
|
|
|
|
|
stopRoomLive?.();
|
|
|
|
|
|
clearActiveRoom();
|
2026-09-29 17:22:34 +03:30
|
|
|
|
clearPendingAttaches();
|
|
|
|
|
|
if (recording.value) mediaRecorder?.stop();
|
|
|
|
|
|
stopRecordTimer();
|
|
|
|
|
|
stopMicTracks();
|
2026-09-29 16:22:37 +03:30
|
|
|
|
});
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<div class="conv-page" :class="{ 'chat-open': mobileChatOpen }">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div class="conv-aurora" aria-hidden="true" />
|
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<header class="conv-head desktop-head">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div>
|
|
|
|
|
|
<p class="eyebrow">درون سازمان</p>
|
|
|
|
|
|
<h1>گفتگو</h1>
|
|
|
|
|
|
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
|
|
|
|
|
</div>
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<div class="head-actions">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<header class="mobile-list-head" :hidden="mobileChatOpen">
|
|
|
|
|
|
<h1>گفتگو</h1>
|
|
|
|
|
|
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<p v-if="error" class="err-banner">{{ error }}</p>
|
|
|
|
|
|
<p v-else-if="loading" class="muted center">در حال آمادهسازی…</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="conv-shell">
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<aside class="conv-rail" :hidden="mobileChatOpen">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div class="rail-actions">
|
|
|
|
|
|
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="canCreateGroup"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="fab ghost"
|
|
|
|
|
|
@click="showNewGroup = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
گروه
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ul class="room-list">
|
|
|
|
|
|
<li v-for="room in sortedRooms" :key="room.externalRoomId">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="room-chip"
|
|
|
|
|
|
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
|
|
|
|
|
@click="openRoom(room)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="avatar-ring">
|
|
|
|
|
|
<UserAvatar :name="roomTitle(room)" :size="40" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="room-meta">
|
|
|
|
|
|
<strong>{{ roomTitle(room) }}</strong>
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<small>{{ roomPreviewText(room) }}</small>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
|
|
|
|
|
<time>{{ formatListTime(room.lastMessageAt) }}</time>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<section
|
|
|
|
|
|
class="conv-stage"
|
|
|
|
|
|
:class="{ empty: !activeRoom, open: mobileChatOpen }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="!activeRoom" class="empty-stage desktop-empty">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div class="empty-glow" />
|
|
|
|
|
|
<UiIcon name="chat" :size="48" />
|
|
|
|
|
|
<h2>یک مکالمه را انتخاب کنید</h2>
|
|
|
|
|
|
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="stage-top">
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
|
|
|
|
|
<UiIcon name="arrow-right" :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="stage-avatar">
|
|
|
|
|
|
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div class="stage-titles">
|
|
|
|
|
|
<h2>{{ roomTitle(activeRoom) }}</h2>
|
|
|
|
|
|
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
|
|
|
|
|
</div>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
2026-09-29 11:56:32 +03:30
|
|
|
|
<ul v-else ref="msgStreamEl" class="msg-stream">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<li
|
|
|
|
|
|
v-for="msg in messages"
|
|
|
|
|
|
:key="msg.id"
|
|
|
|
|
|
class="msg-row"
|
|
|
|
|
|
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
|
|
|
|
|
>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<div class="bubble" :class="{ media: msg.type !== 'text' }">
|
|
|
|
|
|
<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'">
|
2026-09-29 20:18:17 +03:30
|
|
|
|
<video
|
|
|
|
|
|
v-if="mediaSrc(msg)"
|
|
|
|
|
|
class="media-video"
|
|
|
|
|
|
:src="mediaSrc(msg)"
|
|
|
|
|
|
controls
|
|
|
|
|
|
playsinline
|
|
|
|
|
|
preload="metadata"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<a
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="media-file"
|
|
|
|
|
|
:href="msg.content"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="document" :size="18" />
|
|
|
|
|
|
<span>{{ msg.fileName || 'ویدیو' }}</span>
|
|
|
|
|
|
</a>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
|
|
|
|
|
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="msg.type === 'file'">
|
|
|
|
|
|
<a
|
|
|
|
|
|
class="media-file"
|
|
|
|
|
|
:href="mediaSrc(msg)"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener"
|
|
|
|
|
|
:download="msg.fileName || true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="document" :size="18" />
|
|
|
|
|
|
<span>{{ msg.fileName || 'دانلود فایل' }}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
|
|
|
|
|
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<p v-else>{{ msg.content }}</p>
|
2026-09-29 16:22:37 +03:30
|
|
|
|
<div class="bubble-meta">
|
|
|
|
|
|
<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>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
<form class="composer" @submit.prevent="submitMessage">
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<div v-if="pendingAttaches.length" class="attach-preview">
|
|
|
|
|
|
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
|
|
|
|
|
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
|
|
|
|
|
<video v-else-if="item.kind === 'video'" :src="item.previewUrl" muted />
|
|
|
|
|
|
<span v-else class="attach-chip-label">
|
|
|
|
|
|
<UiIcon :name="item.kind === 'voice' ? 'mic' : 'document'" :size="14" />
|
|
|
|
|
|
{{ item.name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button type="button" class="attach-remove" aria-label="حذف" @click="removePending(item.id)">
|
|
|
|
|
|
<UiIcon name="x" :size="12" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="recording" class="rec-bar">
|
|
|
|
|
|
<span class="rec-dot" />
|
|
|
|
|
|
در حال ضبط {{ toFaDigits(recordLabel()) }}
|
|
|
|
|
|
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="composer-row">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref="fileInputEl"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
class="sr-only"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
|
|
|
|
|
|
@change="onFilePicked"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="tool-btn"
|
|
|
|
|
|
title="پیوست فایل / عکس / فیلم"
|
|
|
|
|
|
:disabled="sending || recording"
|
|
|
|
|
|
@click="fileInputEl?.click()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="document" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="tool-btn"
|
|
|
|
|
|
:class="{ on: recording }"
|
2026-09-29 20:18:17 +03:30
|
|
|
|
title="ضبط پیام صوتی"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
:disabled="sending"
|
|
|
|
|
|
@click="toggleVoiceRecord"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="draft"
|
|
|
|
|
|
type="text"
|
2026-09-29 20:18:17 +03:30
|
|
|
|
class="composer-input"
|
|
|
|
|
|
placeholder="پیام…"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
:disabled="sending || recording"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
2026-09-29 20:18:17 +03:30
|
|
|
|
class="send-btn"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="send" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</form>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
|
|
|
|
|
|
<div class="modal-card">
|
|
|
|
|
|
<h3>گفتگوی جدید</h3>
|
|
|
|
|
|
<select v-model="dmPeerId">
|
|
|
|
|
|
<option value="">همکار را انتخاب کنید</option>
|
|
|
|
|
|
<option v-for="p in peers" :key="p.id" :value="p.id">{{ p.fullName }}</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showNewDm = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="primary" @click="startDm">شروع</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
|
|
|
|
|
|
<div class="modal-card wide">
|
|
|
|
|
|
<h3>گروه جدید</h3>
|
|
|
|
|
|
<input v-model="groupName" type="text" placeholder="نام گروه" />
|
|
|
|
|
|
<p class="hint">اعضا</p>
|
|
|
|
|
|
<div class="member-grid">
|
|
|
|
|
|
<label v-for="p in peers" :key="p.id" class="member-check">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
:checked="groupMemberIds.includes(p.id)"
|
|
|
|
|
|
@change="toggleGroupMember(p.id)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ p.fullName }}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showNewGroup = false">انصراف</button>
|
|
|
|
|
|
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
|
|
|
|
|
|
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
|
|
|
|
|
|
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<img :src="lightboxUrl" alt="" />
|
|
|
|
|
|
</dialog>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</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;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.mobile-list-head {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.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;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
height: min(72vh, 720px);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
min-height: 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.rail-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.room-chip {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: grid;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.room-meta {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.room-meta strong {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.room-meta small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
max-width: 100%;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
.unread {
|
|
|
|
|
|
grid-column: 3;
|
|
|
|
|
|
grid-row: 1;
|
|
|
|
|
|
background: #db2777;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
min-width: 1.25rem;
|
|
|
|
|
|
height: 1.25rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.room-chip time {
|
|
|
|
|
|
grid-column: 3;
|
|
|
|
|
|
grid-row: 2;
|
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.conv-stage {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.85);
|
|
|
|
|
|
backdrop-filter: blur(14px);
|
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
min-height: 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stage-top {
|
2026-09-29 20:18:17 +03:30
|
|
|
|
padding: 0.75rem 1rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.stage-top h2 {
|
|
|
|
|
|
margin: 0;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
font-size: 1.05rem;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.kind-tag {
|
|
|
|
|
|
font-size: 0.72rem;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
padding: 0.1rem 0.45rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(124, 58, 237, 0.1);
|
|
|
|
|
|
color: #6d28d9;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
width: fit-content;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.msg-stream {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 1rem 1.15rem;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.65rem;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
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%);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.msg-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-row.mine {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
.bubble {
|
|
|
|
|
|
max-width: min(420px, 85%);
|
|
|
|
|
|
padding: 0.65rem 0.85rem;
|
|
|
|
|
|
border-radius: 18px 18px 18px 4px;
|
|
|
|
|
|
background: #f1f5f9;
|
|
|
|
|
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-row.mine .bubble {
|
|
|
|
|
|
border-radius: 18px 18px 4px 18px;
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
|
|
|
|
|
|
}
|
|
|
|
|
|
.bubble p {
|
|
|
|
|
|
margin: 0 0 0.25rem;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.bubble.media {
|
|
|
|
|
|
padding: 0.45rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.media-image {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
cursor: zoom-in;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
max-width: min(280px, 72vw);
|
|
|
|
|
|
}
|
|
|
|
|
|
.media-image img {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-height: 320px;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
|
|
|
|
|
.upload-veil {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.45);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
2026-09-29 20:36:15 +03:30
|
|
|
|
.media-audio {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: min(280px, 72vw);
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
height: 40px;
|
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.media-video {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: min(280px, 72vw);
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: #0f172a;
|
|
|
|
|
|
}
|
|
|
|
|
|
.media-file {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.45rem 0.65rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.06);
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
max-width: min(280px, 72vw);
|
|
|
|
|
|
}
|
|
|
|
|
|
.media-file span {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.upload-line {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
2026-09-29 16:22:37 +03:30
|
|
|
|
.bubble-meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
margin-top: 0.25rem;
|
|
|
|
|
|
padding-inline: 0.25rem;
|
2026-09-29 16:22:37 +03:30
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.bubble time {
|
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
2026-09-29 16:22:37 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.composer {
|
|
|
|
|
|
display: flex;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
flex-direction: column;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border-top: 1px solid var(--line);
|
2026-09-29 20:18:17 +03:30
|
|
|
|
background: rgba(255, 255, 255, 0.92);
|
|
|
|
|
|
flex-shrink: 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.composer-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
align-items: center;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
min-width: 0;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.composer-input {
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
min-width: 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
padding: 0.65rem 0.85rem;
|
|
|
|
|
|
font: inherit;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
background: #fff;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.tool-btn,
|
|
|
|
|
|
.send-btn {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
width: 2.6rem;
|
|
|
|
|
|
height: 2.6rem;
|
|
|
|
|
|
min-width: 2.6rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
cursor: pointer;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
|
|
|
|
|
.tool-btn {
|
|
|
|
|
|
border: 1px solid var(--line);
|
2026-09-29 20:18:17 +03:30
|
|
|
|
background: var(--surface, #fff);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
.tool-btn.on {
|
|
|
|
|
|
border-color: #ef4444;
|
|
|
|
|
|
color: #ef4444;
|
|
|
|
|
|
background: rgba(239, 68, 68, 0.08);
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.tool-btn:disabled,
|
|
|
|
|
|
.send-btn:disabled {
|
2026-09-29 17:22:34 +03:30
|
|
|
|
opacity: 0.45;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.send-btn {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: #0d9488;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.attach-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
padding-bottom: 0.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip.image,
|
|
|
|
|
|
.attach-chip.video {
|
|
|
|
|
|
width: 72px;
|
|
|
|
|
|
height: 72px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip img,
|
|
|
|
|
|
.attach-chip video {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip-label {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
max-width: 160px;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-remove {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 4px;
|
|
|
|
|
|
left: 4px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
width: 1.35rem;
|
|
|
|
|
|
height: 1.35rem;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.7);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-dot {
|
|
|
|
|
|
width: 0.55rem;
|
|
|
|
|
|
height: 0.55rem;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #ef4444;
|
|
|
|
|
|
animation: pulse 1s infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-stop {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
border: 1px solid #fecaca;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 0.25rem 0.65rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
50% {
|
|
|
|
|
|
opacity: 0.35;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.sr-only {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 1px;
|
|
|
|
|
|
height: 1px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
margin: -1px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
clip: rect(0, 0, 0, 0);
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.empty-stage {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-content: center;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-glow {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 200px;
|
|
|
|
|
|
height: 200px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
|
|
|
|
|
|
filter: blur(8px);
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.lightbox {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 60;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.82);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.lightbox img {
|
|
|
|
|
|
max-width: min(960px, 100%);
|
|
|
|
|
|
max-height: 90vh;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
.lightbox-close {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 1rem;
|
|
|
|
|
|
left: 1rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
width: 2.5rem;
|
|
|
|
|
|
height: 2.5rem;
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.modal {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
|
|
|
|
|
z-index: 40;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.45);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-card {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
|
width: min(400px, 100%);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-card.wide {
|
|
|
|
|
|
width: min(480px, 100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-card select,
|
|
|
|
|
|
.modal-card input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 0.55rem 0.65rem;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
max-height: 200px;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-check {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-actions .primary {
|
|
|
|
|
|
background: #6366f1;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
padding: 0.45rem 0.85rem;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.muted {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.center {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pad {
|
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hint {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
|
|
|
|
|
|
@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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-stream {
|
|
|
|
|
|
padding: 0.75rem 0.7rem 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.bubble {
|
|
|
|
|
|
max-width: min(300px, 82%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.composer {
|
|
|
|
|
|
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
|
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
|
|
|
|
|
|
}
|
|
|
|
|
|
.tool-btn,
|
|
|
|
|
|
.send-btn {
|
|
|
|
|
|
width: 2.75rem;
|
|
|
|
|
|
height: 2.75rem;
|
|
|
|
|
|
min-width: 2.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.composer-input {
|
|
|
|
|
|
padding: 0.7rem 0.8rem;
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</style>
|
2026-09-29 20:18:17 +03:30
|
|
|
|
|