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-10-07 15:23:14 +03:30
|
|
|
|
import { useEscClose } from '@/lib/modal-stack';
|
2026-10-06 15:04:46 +03:30
|
|
|
|
import { matchesSearch } from '@/lib/search';
|
2026-09-30 11:24:58 +03:30
|
|
|
|
import { useRouter } from 'vue-router';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import type {
|
2026-09-30 11:24:58 +03:30
|
|
|
|
ActivityDto,
|
|
|
|
|
|
ActivityType,
|
2026-09-29 10:34:01 +03:30
|
|
|
|
ConversationAccessDto,
|
|
|
|
|
|
ConversationPeerDto,
|
2026-09-30 10:40:29 +03:30
|
|
|
|
ConversationPinnedMessageDto,
|
2026-09-29 10:34:01 +03:30
|
|
|
|
ConversationRoomDto,
|
2026-09-30 11:53:04 +03:30
|
|
|
|
ConversationRoomMemberDto,
|
2026-09-30 11:24:58 +03:30
|
|
|
|
CreateActivityRequest,
|
2026-09-29 10:34:01 +03:30
|
|
|
|
} 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-30 10:40:29 +03:30
|
|
|
|
onMessagePinEvent,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
onMessageReactionEvent,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
onRoomLiveEvent,
|
2026-09-30 09:41:49 +03:30
|
|
|
|
previewLabelForMessage,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
setChatMutePrefs,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
useOrgChat,
|
|
|
|
|
|
type ChatDelivery,
|
2026-09-29 17:22:34 +03:30
|
|
|
|
type ChatMessage,
|
2026-09-30 10:40:29 +03:30
|
|
|
|
type MessagePinEvent,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
type MessageReactionAgg,
|
|
|
|
|
|
type MessageReactionEvent,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
type RoomLiveEvent,
|
|
|
|
|
|
} from '@/lib/useOrgChat';
|
2026-09-30 14:58:44 +03:30
|
|
|
|
import {
|
|
|
|
|
|
crmPathFor,
|
|
|
|
|
|
insertCrmLink,
|
|
|
|
|
|
parseChatMessageText,
|
|
|
|
|
|
} from '@/lib/chatMessageFormat';
|
2026-09-30 11:24:58 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-09-30 16:30:41 +03:30
|
|
|
|
import AskMentoPanel from '@/components/AskMentoPanel.vue';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
|
|
|
|
import UserAvatar from '@/components/UserAvatar.vue';
|
2026-09-30 16:30:41 +03:30
|
|
|
|
import {
|
|
|
|
|
|
brand,
|
|
|
|
|
|
type PaginatedResponse,
|
|
|
|
|
|
type PersonDto,
|
|
|
|
|
|
type PipelineBoardDto,
|
|
|
|
|
|
type TicketDto,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
} from '@mento/shared';
|
2026-09-29 10:34:01 +03:30
|
|
|
|
|
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-30 11:24:58 +03:30
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const auth = useAuthStore();
|
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);
|
2026-09-30 11:08:33 +03:30
|
|
|
|
const showNewChannel = ref(false);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const groupName = ref('');
|
|
|
|
|
|
const groupMemberIds = ref<string[]>([]);
|
2026-09-30 11:08:33 +03:30
|
|
|
|
const channelName = ref('');
|
|
|
|
|
|
const channelMemberIds = ref<string[]>([]);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const dmPeerId = ref('');
|
2026-09-30 11:24:58 +03:30
|
|
|
|
const peerPickerQuery = 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-30 09:19:36 +03:30
|
|
|
|
const draftInputEl = ref<HTMLTextAreaElement | null>(null);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
const emojiOpen = ref(false);
|
|
|
|
|
|
const editingMessageId = ref<string | null>(null);
|
2026-09-30 09:41:49 +03:30
|
|
|
|
const replyTo = ref<ChatMessage | null>(null);
|
|
|
|
|
|
const msgMenu = ref<{ id: string; x: number; y: number; sheet: boolean } | null>(null);
|
|
|
|
|
|
const msgMenuEl = ref<HTMLElement | null>(null);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
|
|
|
|
|
|
const CHAT_EMOJIS = [
|
|
|
|
|
|
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
|
|
|
|
|
|
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
|
|
|
|
|
|
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
|
|
|
|
|
|
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
|
|
|
|
|
|
];
|
2026-09-29 10:34:01 +03:30
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
|
const {
|
|
|
|
|
|
messages,
|
|
|
|
|
|
loadingMessages,
|
2026-09-30 11:53:04 +03:30
|
|
|
|
hasMoreMessages,
|
|
|
|
|
|
loadingOlderMessages,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
status,
|
|
|
|
|
|
loadMessages,
|
2026-09-30 11:53:04 +03:30
|
|
|
|
loadOlderMessages,
|
|
|
|
|
|
sendTyping,
|
2026-09-30 12:20:49 +03:30
|
|
|
|
typingMap,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
sendText,
|
2026-09-29 17:22:34 +03:30
|
|
|
|
sendFile,
|
2026-09-29 21:06:04 +03:30
|
|
|
|
editMessage,
|
|
|
|
|
|
deleteMessage,
|
2026-09-29 21:26:07 +03:30
|
|
|
|
deleteMessages,
|
|
|
|
|
|
forwardMessages,
|
2026-09-30 10:40:29 +03:30
|
|
|
|
pinChatMessage,
|
|
|
|
|
|
unpinChatMessage,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
toggleMessageReaction,
|
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-30 10:40:29 +03:30
|
|
|
|
let stopPinLive: (() => boolean) | undefined;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
let stopReactLive: (() => 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
|
|
|
|
|
2026-09-29 21:26:07 +03:30
|
|
|
|
const selectMode = ref(false);
|
|
|
|
|
|
const selectedIds = ref<string[]>([]);
|
|
|
|
|
|
const showForward = ref(false);
|
|
|
|
|
|
const forwardBusy = ref(false);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
const roomListQuery = ref('');
|
2026-09-30 11:08:33 +03:30
|
|
|
|
const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all');
|
2026-09-30 10:40:29 +03:30
|
|
|
|
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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);
|
2026-09-30 16:30:41 +03:30
|
|
|
|
const showAiAssist = ref(false);
|
|
|
|
|
|
const aiChatContext = ref('');
|
2026-09-30 10:40:29 +03:30
|
|
|
|
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);
|
2026-09-30 11:24:58 +03:30
|
|
|
|
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('');
|
2026-09-30 11:53:04 +03:30
|
|
|
|
const showMembers = ref(false);
|
|
|
|
|
|
const roomMembers = ref<ConversationRoomMemberDto[]>([]);
|
|
|
|
|
|
const membersBusy = ref(false);
|
|
|
|
|
|
const membersError = ref('');
|
|
|
|
|
|
const memberAddId = ref('');
|
|
|
|
|
|
const typingTick = ref(0);
|
2026-09-30 11:24:58 +03:30
|
|
|
|
|
|
|
|
|
|
const ACTIVITY_TYPE_OPTS: Array<{ value: ActivityType; label: string }> = [
|
|
|
|
|
|
{ value: 'task', label: 'وظیفه' },
|
|
|
|
|
|
{ value: 'call', label: 'تماس' },
|
|
|
|
|
|
{ value: 'meeting', label: 'جلسه' },
|
|
|
|
|
|
{ value: 'note', label: 'یادداشت' },
|
|
|
|
|
|
];
|
2026-09-29 21:26:07 +03:30
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
2026-09-30 11:08:33 +03:30
|
|
|
|
const canCreateChannel = computed(
|
|
|
|
|
|
() => access.value?.canCreateChannel ?? access.value?.canCreateGroup ?? false,
|
|
|
|
|
|
);
|
|
|
|
|
|
const canPostInActive = computed(() => {
|
|
|
|
|
|
const room = activeRoom.value;
|
|
|
|
|
|
if (!room) return false;
|
|
|
|
|
|
if (room.kind !== 'channel') return true;
|
|
|
|
|
|
return room.canPost !== false;
|
|
|
|
|
|
});
|
2026-09-29 10:34:01 +03:30
|
|
|
|
|
2026-09-30 11:24:58 +03:30
|
|
|
|
const filteredPeers = computed(() => {
|
2026-10-06 15:04:46 +03:30
|
|
|
|
const q = peerPickerQuery.value.trim();
|
2026-09-30 11:24:58 +03:30
|
|
|
|
if (!q) return peers.value;
|
2026-10-06 15:04:46 +03:30
|
|
|
|
return peers.value.filter((p) => matchesSearch(q, [p.fullName]));
|
2026-09-30 11:24:58 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const selectedGroupPeers = computed(() =>
|
|
|
|
|
|
peers.value.filter((p) => groupMemberIds.value.includes(p.id)),
|
|
|
|
|
|
);
|
|
|
|
|
|
const selectedChannelPeers = computed(() =>
|
|
|
|
|
|
peers.value.filter((p) => channelMemberIds.value.includes(p.id)),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-30 11:53:04 +03:30
|
|
|
|
const typingLabel = computed(() => {
|
|
|
|
|
|
void typingTick.value;
|
|
|
|
|
|
const roomId = activeRoom.value?.externalRoomId;
|
|
|
|
|
|
if (!roomId) return '';
|
2026-09-30 12:20:49 +03:30
|
|
|
|
const list = (typingMap.value?.[roomId] || []).filter(
|
2026-09-30 12:43:19 +03:30
|
|
|
|
(t) => Date.now() - t.at < 4000,
|
2026-09-30 12:20:49 +03:30
|
|
|
|
);
|
|
|
|
|
|
if (!list.length) return '';
|
|
|
|
|
|
const names = list
|
|
|
|
|
|
.map((t) => {
|
2026-09-30 12:43:19 +03:30
|
|
|
|
if (t.name) return t.name;
|
2026-09-30 12:20:49 +03:30
|
|
|
|
if (t.userId === '__someone__') return '';
|
2026-09-30 12:43:19 +03:30
|
|
|
|
return (
|
|
|
|
|
|
peers.value.find((p) => p.chatUserId === t.userId)?.fullName ||
|
|
|
|
|
|
peers.value.find((p) => p.id === t.userId)?.fullName ||
|
|
|
|
|
|
''
|
|
|
|
|
|
);
|
2026-09-30 12:20:49 +03:30
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean);
|
2026-09-30 11:53:04 +03:30
|
|
|
|
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;
|
2026-09-30 12:20:49 +03:30
|
|
|
|
// اگر فلگ از API نیامده ولی کاربر سازنده/ادمین سازمان است، مدیریت را نشان بده
|
|
|
|
|
|
if (room.canManageMembers === true) return true;
|
|
|
|
|
|
if (room.canManageMembers === false) return false;
|
|
|
|
|
|
return room.kind === 'group' || room.kind === 'channel';
|
2026-09-30 11:53:04 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const membersAddCandidates = computed(() => {
|
|
|
|
|
|
const taken = new Set(roomMembers.value.map((m) => m.userId));
|
|
|
|
|
|
return peers.value.filter((p) => !taken.has(p.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
|
async function scrollToBottom() {
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
const el = msgStreamEl.value;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
el.scrollTop = el.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const sortedRooms = computed(() =>
|
|
|
|
|
|
[...rooms.value].sort((a, b) => {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
const pinA = a.pinned ? 1 : 0;
|
|
|
|
|
|
const pinB = b.pinned ? 1 : 0;
|
|
|
|
|
|
if (pinA !== pinB) return pinB - pinA;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
|
|
|
|
|
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
|
|
|
|
|
return tb - ta;
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-30 10:40:29 +03:30
|
|
|
|
const filteredRooms = computed(() => {
|
2026-10-06 15:04:46 +03:30
|
|
|
|
const q = roomListQuery.value.trim();
|
2026-09-30 10:40:29 +03:30
|
|
|
|
return sortedRooms.value.filter((room) => {
|
|
|
|
|
|
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
|
|
|
|
|
|
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (!q) return true;
|
2026-10-06 15:04:46 +03:30
|
|
|
|
return matchesSearch(q, [roomTitle(room), room.lastMessagePreview]);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const mentionCandidates = computed(() => {
|
|
|
|
|
|
const state = mentionState.value;
|
|
|
|
|
|
if (!state) return [] as ConversationPeerDto[];
|
2026-10-06 15:04:46 +03:30
|
|
|
|
const q = state.query.trim();
|
2026-09-30 10:40:29 +03:30
|
|
|
|
return peers.value
|
2026-10-06 15:04:46 +03:30
|
|
|
|
.filter((p) => !q || matchesSearch(q, [p.fullName]))
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.slice(0, 8);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const chatSearchHits = computed(() => {
|
2026-10-06 15:04:46 +03:30
|
|
|
|
const q = chatSearchQuery.value.trim();
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
|
|
|
|
|
|
return messages.value.filter((m) => {
|
|
|
|
|
|
if (m.deleted) return false;
|
2026-10-06 15:04:46 +03:30
|
|
|
|
if (m.type === 'text') return matchesSearch(q, [m.content]);
|
|
|
|
|
|
return matchesSearch(q, [previewLabelForMessage(m)]);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 ?? '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
function roomTitle(room: ConversationRoomDto) {
|
2026-09-30 11:08:33 +03:30
|
|
|
|
if (room.kind === 'group' || room.kind === 'channel') return room.name;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
|
|
|
|
|
return peer?.fullName ?? room.name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 14:23:59 +03:30
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:08:33 +03:30
|
|
|
|
function roomKindLabel(kind: ConversationRoomDto['kind']) {
|
|
|
|
|
|
if (kind === 'channel') return 'کانال';
|
|
|
|
|
|
if (kind === 'group') return 'گروه';
|
|
|
|
|
|
return 'خصوصی';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
|
type TextSeg = ReturnType<typeof parseChatMessageText>[number];
|
2026-09-30 10:40:29 +03:30
|
|
|
|
|
|
|
|
|
|
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
|
2026-09-30 14:58:44 +03:30
|
|
|
|
return parseChatMessageText(content, {
|
|
|
|
|
|
mentionNames: peers.value.map((p) => p.fullName),
|
|
|
|
|
|
highlightQuery,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
}
|
2026-09-30 14:58:44 +03:30
|
|
|
|
setChatMutePrefs(prefs, auth.user?.fullName || '');
|
2026-09-30 10:40:29 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
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-30 13:01:42 +03:30
|
|
|
|
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)} نفر در حال نوشتن…`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
|
function roomPreviewText(room: ConversationRoomDto) {
|
2026-09-30 13:01:42 +03:30
|
|
|
|
const typing = roomTypingPreview(room.externalRoomId);
|
|
|
|
|
|
if (typing) return typing;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
const raw = (room.lastMessagePreview || '').trim();
|
|
|
|
|
|
if (!raw) return 'بدون پیام';
|
|
|
|
|
|
if (raw === 'وویس') return 'پیام صوتی';
|
|
|
|
|
|
return raw;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMobileChat() {
|
|
|
|
|
|
clearActiveRoom();
|
|
|
|
|
|
activeRoom.value = null;
|
|
|
|
|
|
clearPendingAttaches();
|
2026-09-29 21:06:04 +03:30
|
|
|
|
cancelEdit();
|
2026-09-30 09:41:49 +03:30
|
|
|
|
cancelReply();
|
2026-09-29 21:26:07 +03:30
|
|
|
|
exitSelectMode();
|
2026-09-29 21:06:04 +03:30
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
msgMenu.value = null;
|
2026-09-29 21:26:07 +03:30
|
|
|
|
showForward.value = false;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
closeChatSearch();
|
|
|
|
|
|
mentionState.value = null;
|
|
|
|
|
|
pinnedMessage.value = null;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
closeActivityFromMsg();
|
2026-09-29 20:18:17 +03:30
|
|
|
|
if (recording.value) mediaRecorder?.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
|
2026-09-29 21:06:04 +03:30
|
|
|
|
const canSend = computed(
|
|
|
|
|
|
() => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
const showMicInsteadOfSend = computed(
|
|
|
|
|
|
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
|
|
|
|
|
|
);
|
2026-09-29 21:26:07 +03:30
|
|
|
|
const selectedCount = computed(() => selectedIds.value.length);
|
|
|
|
|
|
const forwardTargets = computed(() =>
|
|
|
|
|
|
sortedRooms.value.filter((r) => r.externalRoomId !== activeRoom.value?.externalRoomId),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function isSelected(id: string) {
|
|
|
|
|
|
return selectedIds.value.includes(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function exitSelectMode() {
|
|
|
|
|
|
selectMode.value = false;
|
|
|
|
|
|
selectedIds.value = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function enterSelectMode(msg?: ChatMessage) {
|
|
|
|
|
|
selectMode.value = true;
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
cancelEdit();
|
|
|
|
|
|
if (msg && !msg.deleted) selectedIds.value = [msg.id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleSelect(msg: ChatMessage) {
|
|
|
|
|
|
if (msg.deleted || msg.optimistic) return;
|
|
|
|
|
|
if (!selectMode.value) {
|
|
|
|
|
|
enterSelectMode(msg);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isSelected(msg.id)) {
|
|
|
|
|
|
selectedIds.value = selectedIds.value.filter((id) => id !== msg.id);
|
|
|
|
|
|
if (!selectedIds.value.length) exitSelectMode();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedIds.value = [...selectedIds.value, msg.id];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectedMessages(): ChatMessage[] {
|
|
|
|
|
|
const set = new Set(selectedIds.value);
|
|
|
|
|
|
return messages.value.filter((m) => set.has(m.id) && !m.deleted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteSelected() {
|
|
|
|
|
|
if (!activeRoom.value || !selectedIds.value.length) return;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
const list = selectedMessages();
|
|
|
|
|
|
if (!list.length) return;
|
|
|
|
|
|
const mineCount = list.filter((m) => isMineMessage(m)).length;
|
|
|
|
|
|
const otherCount = list.length - mineCount;
|
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
if (mineCount) parts.push(`${mineCount} پیام خودتان برای همه`);
|
|
|
|
|
|
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
|
|
|
|
|
|
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
|
2026-09-29 21:26:07 +03:30
|
|
|
|
try {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
const pinnedId = pinnedMessage.value?.messageId;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
await deleteMessages(
|
|
|
|
|
|
activeRoom.value.externalRoomId,
|
|
|
|
|
|
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
|
|
|
|
|
|
);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null;
|
2026-09-29 21:26:07 +03:30
|
|
|
|
exitSelectMode();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openForwardPicker(msgs?: ChatMessage[]) {
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
if (msgs?.length) {
|
|
|
|
|
|
selectedIds.value = msgs.map((m) => m.id);
|
|
|
|
|
|
selectMode.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!selectedIds.value.length) return;
|
|
|
|
|
|
showForward.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmForward(targetRoomId: string) {
|
|
|
|
|
|
if (!targetRoomId || forwardBusy.value) return;
|
|
|
|
|
|
const list = selectedMessages();
|
|
|
|
|
|
if (!list.length) return;
|
|
|
|
|
|
forwardBusy.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await forwardMessages(targetRoomId, list);
|
|
|
|
|
|
showForward.value = false;
|
|
|
|
|
|
exitSelectMode();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'ارسال برای دیگری ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
forwardBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
|
|
|
|
|
|
|
function insertEmoji(mark: string) {
|
|
|
|
|
|
const el = draftInputEl.value;
|
|
|
|
|
|
const cur = draft.value;
|
|
|
|
|
|
if (el && typeof el.selectionStart === 'number') {
|
|
|
|
|
|
const start = el.selectionStart;
|
|
|
|
|
|
const end = el.selectionEnd ?? start;
|
|
|
|
|
|
draft.value = cur.slice(0, start) + mark + cur.slice(end);
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
el.focus();
|
|
|
|
|
|
const pos = start + mark.length;
|
|
|
|
|
|
el.setSelectionRange(pos, pos);
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
draft.value = cur + mark;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cancelEdit() {
|
|
|
|
|
|
editingMessageId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:41:49 +03:30
|
|
|
|
function cancelReply() {
|
|
|
|
|
|
replyTo.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startReplyMessage(msg: ChatMessage) {
|
|
|
|
|
|
if (msg.deleted || msg.optimistic) return;
|
|
|
|
|
|
replyTo.value = msg;
|
|
|
|
|
|
editingMessageId.value = null;
|
|
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
nextTick(() => draftInputEl.value?.focus());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
|
function startEditMessage(msg: ChatMessage) {
|
|
|
|
|
|
if (msg.deleted || msg.type !== 'text' || msg.optimistic) return;
|
|
|
|
|
|
editingMessageId.value = msg.id;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
replyTo.value = null;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
draft.value = msg.content;
|
|
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
clearPendingAttaches();
|
|
|
|
|
|
nextTick(() => draftInputEl.value?.focus());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:41:49 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 10:40:29 +03:30
|
|
|
|
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 : 'سنجاق گفتگو ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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('*', '*');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 15:23:19 +03:30
|
|
|
|
function formatDraftItalic() {
|
|
|
|
|
|
wrapDraftSelection('_', '_');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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
|
2026-10-06 15:04:46 +03:30
|
|
|
|
.filter(
|
|
|
|
|
|
(d) =>
|
|
|
|
|
|
!q.trim() ||
|
|
|
|
|
|
d.id.includes(q) ||
|
|
|
|
|
|
matchesSearch(q, [d.title, d.person?.fullName, d.company?.name]),
|
|
|
|
|
|
)
|
2026-09-30 14:58:44 +03:30
|
|
|
|
.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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 16:30:41 +03:30
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 10:40:29 +03:30
|
|
|
|
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();
|
2026-09-30 11:53:04 +03:30
|
|
|
|
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 {
|
2026-09-30 12:20:49 +03:30
|
|
|
|
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 = 'عضوی برای این گفتگو ثبت نشده. با «افزودن» اعضا را اضافه کنید.';
|
|
|
|
|
|
}
|
2026-09-30 11:53:04 +03:30
|
|
|
|
} catch (e) {
|
2026-09-30 12:20:49 +03:30
|
|
|
|
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 : 'بارگذاری اعضا ناموفق بود.';
|
|
|
|
|
|
}
|
2026-09-30 11:53:04 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
membersBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function addRoomMember() {
|
|
|
|
|
|
if (!activeRoom.value || !memberAddId.value) return;
|
|
|
|
|
|
membersBusy.value = true;
|
|
|
|
|
|
membersError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomMemberDto[]>('/conversations/rooms/members', {
|
|
|
|
|
|
externalRoomId: activeRoom.value.externalRoomId,
|
|
|
|
|
|
userId: memberAddId.value,
|
|
|
|
|
|
role: 'member',
|
|
|
|
|
|
});
|
|
|
|
|
|
roomMembers.value = Array.isArray(data) ? data : [];
|
|
|
|
|
|
memberAddId.value = '';
|
|
|
|
|
|
// refresh canPost/canManage for current user list
|
|
|
|
|
|
void refreshRoomsSoft();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'افزودن عضو ناموفق بود.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
membersError.value = 'افزودن عضو ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
membersBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeRoomMember(userId: string) {
|
|
|
|
|
|
if (!activeRoom.value || !canManageActiveMembers.value) return;
|
|
|
|
|
|
if (!window.confirm('این عضو از گفتگو حذف شود؟')) return;
|
|
|
|
|
|
membersBusy.value = true;
|
|
|
|
|
|
membersError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomMemberDto[]>(
|
|
|
|
|
|
'/conversations/rooms/members/remove',
|
|
|
|
|
|
{
|
|
|
|
|
|
externalRoomId: activeRoom.value.externalRoomId,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
roomMembers.value = Array.isArray(data) ? data : [];
|
|
|
|
|
|
void refreshRoomsSoft();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'حذف عضو ناموفق بود.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
membersError.value = 'حذف عضو ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
membersBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleMemberAdmin(member: ConversationRoomMemberDto) {
|
|
|
|
|
|
if (!activeRoom.value || !canManageActiveMembers.value) return;
|
|
|
|
|
|
const nextRole = member.role === 'admin' ? 'member' : 'admin';
|
|
|
|
|
|
membersBusy.value = true;
|
|
|
|
|
|
membersError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomMemberDto[]>(
|
|
|
|
|
|
'/conversations/rooms/members/role',
|
|
|
|
|
|
{
|
|
|
|
|
|
externalRoomId: activeRoom.value.externalRoomId,
|
|
|
|
|
|
userId: member.userId,
|
|
|
|
|
|
role: nextRole,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
roomMembers.value = Array.isArray(data) ? data : [];
|
|
|
|
|
|
void refreshRoomsSoft();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'تغییر نقش ناموفق بود.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
membersError.value = 'تغییر نقش ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
membersBusy.value = false;
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
|
async function confirmDeleteMessage(msg: ChatMessage) {
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
const mine = isMineMessage(msg);
|
|
|
|
|
|
const ok = window.confirm(
|
|
|
|
|
|
mine
|
|
|
|
|
|
? 'این پیام برای همه حذف شود؟'
|
|
|
|
|
|
: 'این پیام فقط برای شما حذف شود؟ (برای طرف مقابل باقی میماند)',
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!ok) return;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
try {
|
2026-09-30 09:19:36 +03:30
|
|
|
|
await deleteMessage(activeRoom.value.externalRoomId, msg.id, {
|
|
|
|
|
|
forEveryone: mine,
|
|
|
|
|
|
});
|
2026-09-29 21:06:04 +03:30
|
|
|
|
if (editingMessageId.value === msg.id) cancelEdit();
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 10:04:54 +03:30
|
|
|
|
function isMobileComposer() {
|
|
|
|
|
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function insertComposerNewline() {
|
|
|
|
|
|
const el = draftInputEl.value;
|
|
|
|
|
|
if (!el) {
|
|
|
|
|
|
draft.value += '\n';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const start = el.selectionStart ?? draft.value.length;
|
|
|
|
|
|
const end = el.selectionEnd ?? start;
|
|
|
|
|
|
draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const pos = start + 1;
|
|
|
|
|
|
el.focus();
|
|
|
|
|
|
el.setSelectionRange(pos, pos);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 09:19:36 +03:30
|
|
|
|
function onComposerKeydown(event: KeyboardEvent) {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-30 09:19:36 +03:30
|
|
|
|
if (event.key !== 'Enter') return;
|
2026-09-30 10:04:54 +03:30
|
|
|
|
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
|
|
|
|
|
|
if (isMobileComposer()) return;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
|
|
|
|
event.preventDefault();
|
2026-09-30 10:04:54 +03:30
|
|
|
|
insertComposerNewline();
|
2026-09-30 09:19:36 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event.shiftKey) return;
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void submitMessage();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
|
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
2026-09-29 21:26:07 +03:30
|
|
|
|
if (msg.deleted || msg.optimistic) return;
|
|
|
|
|
|
if (selectMode.value) {
|
|
|
|
|
|
toggleSelect(msg);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
let x = 0;
|
|
|
|
|
|
let y = 0;
|
|
|
|
|
|
if ('changedTouches' in event && event.changedTouches[0]) {
|
|
|
|
|
|
x = event.changedTouches[0].clientX;
|
|
|
|
|
|
y = event.changedTouches[0].clientY;
|
|
|
|
|
|
} else if ('touches' in event && event.touches[0]) {
|
|
|
|
|
|
x = event.touches[0].clientX;
|
|
|
|
|
|
y = event.touches[0].clientY;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
x = (event as MouseEvent).clientX;
|
|
|
|
|
|
y = (event as MouseEvent).clientY;
|
|
|
|
|
|
}
|
2026-09-30 09:41:49 +03:30
|
|
|
|
const sheet = window.matchMedia('(max-width: 900px)').matches;
|
|
|
|
|
|
msgMenu.value = { id: msg.id, x, y, sheet };
|
|
|
|
|
|
if (sheet) return;
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const el = msgMenuEl.value;
|
|
|
|
|
|
if (!el || !msgMenu.value) return;
|
|
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
|
|
const pad = 12;
|
|
|
|
|
|
let left = x - rect.width / 2;
|
|
|
|
|
|
let top = y - rect.height - 10;
|
|
|
|
|
|
if (top < pad) top = y + 10;
|
|
|
|
|
|
if (top + rect.height > window.innerHeight - pad) {
|
|
|
|
|
|
top = Math.max(pad, window.innerHeight - pad - rect.height);
|
|
|
|
|
|
}
|
|
|
|
|
|
left = Math.min(Math.max(left, pad), window.innerWidth - pad - rect.width);
|
|
|
|
|
|
msgMenu.value = { ...msgMenu.value, x: left, y: top };
|
|
|
|
|
|
});
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function menuMessage(): ChatMessage | null {
|
|
|
|
|
|
if (!msgMenu.value) return null;
|
|
|
|
|
|
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 21:26:07 +03:30
|
|
|
|
function isMineMessage(msg: ChatMessage | null | undefined) {
|
|
|
|
|
|
if (!msg) return false;
|
|
|
|
|
|
return msg.senderId === chatUserId.value || Boolean(msg.optimistic);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
function clearLongPress() {
|
|
|
|
|
|
if (longPressTimer) {
|
|
|
|
|
|
clearTimeout(longPressTimer);
|
|
|
|
|
|
longPressTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function onBubbleTouchStart(msg: ChatMessage, event: TouchEvent) {
|
|
|
|
|
|
clearLongPress();
|
|
|
|
|
|
longPressTimer = setTimeout(() => {
|
|
|
|
|
|
longPressTimer = null;
|
|
|
|
|
|
openMsgMenu(msg, event);
|
|
|
|
|
|
}, 480);
|
|
|
|
|
|
}
|
|
|
|
|
|
function onBubbleTouchEnd() {
|
|
|
|
|
|
clearLongPress();
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
|
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;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-29 16:22:37 +03:30
|
|
|
|
const next: ConversationRoomDto = {
|
|
|
|
|
|
...room,
|
|
|
|
|
|
lastMessagePreview: event.preview,
|
|
|
|
|
|
lastMessageAt: event.at,
|
2026-09-30 14:58:44 +03:30
|
|
|
|
unreadCount: (room.unreadCount || 0) + unreadBump,
|
2026-09-29 16:22:37 +03:30
|
|
|
|
};
|
2026-09-30 14:58:44 +03:30
|
|
|
|
if (isActive) next.unreadCount = 0;
|
2026-09-29 16:22:37 +03:30
|
|
|
|
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;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
syncMutePrefs();
|
2026-09-30 11:53:04 +03:30
|
|
|
|
if (activeRoom.value) {
|
|
|
|
|
|
const next = data.find((r) => r.externalRoomId === activeRoom.value!.externalRoomId);
|
|
|
|
|
|
if (next) activeRoom.value = { ...next, unreadCount: 0 };
|
|
|
|
|
|
}
|
2026-09-29 16:22:37 +03:30
|
|
|
|
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-30 14:58:44 +03:30
|
|
|
|
syncMutePrefs();
|
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-30 10:40:29 +03:30
|
|
|
|
closeChatSearch();
|
|
|
|
|
|
mentionState.value = null;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
await loadMessages(room.externalRoomId);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
await refreshPinnedMessage(room.externalRoomId);
|
2026-09-30 14:58:44 +03:30
|
|
|
|
await refreshReactions(room.externalRoomId);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
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;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
if (!canPostInActive.value) {
|
|
|
|
|
|
error.value = 'فقط مدیران کانال میتوانند پیام بفرستند.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
if (sending.value || recording.value) return;
|
|
|
|
|
|
const text = draft.value.trim();
|
|
|
|
|
|
const attaches = [...pendingAttaches.value];
|
2026-09-29 21:06:04 +03:30
|
|
|
|
const editingId = editingMessageId.value;
|
|
|
|
|
|
|
|
|
|
|
|
if (editingId) {
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
sending.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await editMessage(activeRoom.value.externalRoomId, editingId, text);
|
|
|
|
|
|
draft.value = '';
|
|
|
|
|
|
editingMessageId.value = null;
|
|
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'ویرایش پیام ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sending.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
|
if (!text && !attaches.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
sending.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
const roomId = activeRoom.value.externalRoomId;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
const replyId = replyTo.value?.id ?? null;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
draft.value = '';
|
|
|
|
|
|
pendingAttaches.value = [];
|
2026-09-29 21:06:04 +03:30
|
|
|
|
emojiOpen.value = false;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
replyTo.value = null;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
try {
|
2026-09-29 17:22:34 +03:30
|
|
|
|
if (text) {
|
2026-09-30 09:41:49 +03:30
|
|
|
|
await sendText(roomId, text, replyId);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
|
|
|
|
|
for (const item of attaches) {
|
|
|
|
|
|
try {
|
2026-09-30 09:41:49 +03:30
|
|
|
|
await sendFile(roomId, item.file, replyId);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
if (item.previewUrl.startsWith('blob:')) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
URL.revokeObjectURL(item.previewUrl);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = e instanceof Error ? e.message : 'ارسال پیام ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
sending.value = false;
|
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) {
|
2026-10-04 12:12:05 +03:30
|
|
|
|
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 = 'ساخت گروه ناموفق بود.';
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'ساخت گروه ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:08:33 +03:30
|
|
|
|
async function startChannel() {
|
|
|
|
|
|
if (!channelName.value.trim() || !channelMemberIds.value.length) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/channel', {
|
|
|
|
|
|
name: channelName.value.trim(),
|
|
|
|
|
|
memberUserIds: channelMemberIds.value,
|
|
|
|
|
|
});
|
|
|
|
|
|
rooms.value = [{ ...data, canPost: true }, ...rooms.value];
|
|
|
|
|
|
showNewChannel.value = false;
|
|
|
|
|
|
channelName.value = '';
|
|
|
|
|
|
channelMemberIds.value = [];
|
|
|
|
|
|
await openRoom({ ...data, canPost: true });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
|
|
|
|
|
error.value = 'اجازه ساخت کانال را ندارید.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'ساخت کانال ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
function toggleGroupMember(id: string) {
|
|
|
|
|
|
if (groupMemberIds.value.includes(id)) {
|
|
|
|
|
|
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
groupMemberIds.value = [...groupMemberIds.value, id];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:08:33 +03:30
|
|
|
|
function toggleChannelMember(id: string) {
|
|
|
|
|
|
if (channelMemberIds.value.includes(id)) {
|
|
|
|
|
|
channelMemberIds.value = channelMemberIds.value.filter((x) => x !== id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
channelMemberIds.value = [...channelMemberIds.value, id];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 15:23:14 +03:30
|
|
|
|
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 = ''; });
|
2026-10-04 12:12:05 +03:30
|
|
|
|
|
2026-09-30 11:24:58 +03:30
|
|
|
|
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 = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
watch(activeRoom, () => {
|
|
|
|
|
|
draft.value = '';
|
2026-09-29 17:22:34 +03:30
|
|
|
|
clearPendingAttaches();
|
2026-09-29 21:26:07 +03:30
|
|
|
|
exitSelectMode();
|
|
|
|
|
|
cancelEdit();
|
2026-09-30 09:41:49 +03:30
|
|
|
|
cancelReply();
|
2026-09-29 21:26:07 +03:30
|
|
|
|
emojiOpen.value = false;
|
|
|
|
|
|
msgMenu.value = null;
|
|
|
|
|
|
showForward.value = false;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
closeChatSearch();
|
|
|
|
|
|
mentionState.value = null;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
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,
|
|
|
|
|
|
() => {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (chatSearchOpen.value) return;
|
2026-09-29 11:56:32 +03:30
|
|
|
|
void scrollToBottom();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
watch(loadingMessages, (v) => {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
if (!v && !chatSearchOpen.value) void scrollToBottom();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch(chatSearchHits, (hits) => {
|
|
|
|
|
|
if (!chatSearchOpen.value) return;
|
|
|
|
|
|
if (!hits.length) {
|
|
|
|
|
|
chatSearchIndex.value = 0;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (chatSearchIndex.value >= hits.length) chatSearchIndex.value = 0;
|
|
|
|
|
|
nextTick(() => scrollToMessage(activeSearchHitId.value));
|
2026-09-29 11:56:32 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-30 11:53:04 +03:30
|
|
|
|
let typingUiTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
onMounted(() => {
|
2026-09-29 16:22:37 +03:30
|
|
|
|
setConversationsPageOpen(true);
|
|
|
|
|
|
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
2026-09-30 10:40:29 +03:30
|
|
|
|
stopPinLive = onMessagePinEvent(applyMessagePin);
|
2026-09-30 14:58:44 +03:30
|
|
|
|
stopReactLive = onMessageReactionEvent(applyReactionLive);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
void bootstrap();
|
2026-09-30 11:53:04 +03:30
|
|
|
|
typingUiTimer = setInterval(() => {
|
|
|
|
|
|
typingTick.value += 1;
|
|
|
|
|
|
}, 1000);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
});
|
2026-09-29 16:22:37 +03:30
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
setConversationsPageOpen(false);
|
|
|
|
|
|
stopRoomLive?.();
|
2026-09-30 10:40:29 +03:30
|
|
|
|
stopPinLive?.();
|
2026-09-30 14:58:44 +03:30
|
|
|
|
stopReactLive?.();
|
2026-09-30 11:53:04 +03:30
|
|
|
|
if (typingUiTimer) clearInterval(typingUiTimer);
|
2026-09-29 16:22:37 +03:30
|
|
|
|
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">
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<button type="button" class="fab" @click="openNewDm">+ گفتگو</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="canCreateGroup"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="fab ghost"
|
2026-09-30 11:24:58 +03:30
|
|
|
|
@click="openNewGroup"
|
2026-09-29 10:34:01 +03:30
|
|
|
|
>
|
|
|
|
|
|
گروه
|
|
|
|
|
|
</button>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="canCreateChannel"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="fab ghost"
|
2026-09-30 11:24:58 +03:30
|
|
|
|
@click="openNewChannel"
|
2026-09-30 11:08:33 +03:30
|
|
|
|
>
|
|
|
|
|
|
کانال
|
|
|
|
|
|
</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<div class="rail-filters">
|
|
|
|
|
|
<label class="rail-search">
|
|
|
|
|
|
<UiIcon name="search" :size="16" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="roomListQuery"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
placeholder="جستجوی گفتگو…"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="rail-tabs" role="tablist" aria-label="نوع گفتگو">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
:aria-selected="roomListTab === 'all'"
|
|
|
|
|
|
:class="{ active: roomListTab === 'all' }"
|
|
|
|
|
|
@click="roomListTab = 'all'"
|
|
|
|
|
|
>
|
|
|
|
|
|
همه
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
:aria-selected="roomListTab === 'dm'"
|
|
|
|
|
|
:class="{ active: roomListTab === 'dm' }"
|
|
|
|
|
|
@click="roomListTab = 'dm'"
|
|
|
|
|
|
>
|
|
|
|
|
|
خصوصی
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
:aria-selected="roomListTab === 'group'"
|
|
|
|
|
|
:class="{ active: roomListTab === 'group' }"
|
|
|
|
|
|
@click="roomListTab = 'group'"
|
|
|
|
|
|
>
|
|
|
|
|
|
گروه
|
|
|
|
|
|
</button>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
:aria-selected="roomListTab === 'channel'"
|
|
|
|
|
|
:class="{ active: roomListTab === 'channel' }"
|
|
|
|
|
|
@click="roomListTab = 'channel'"
|
|
|
|
|
|
>
|
|
|
|
|
|
کانال
|
|
|
|
|
|
</button>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<ul class="room-list">
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<li v-if="!filteredRooms.length" class="rail-empty">
|
|
|
|
|
|
{{ roomListQuery.trim() ? 'گفتگویی پیدا نشد' : 'گفتگویی در این بخش نیست' }}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li v-for="room in filteredRooms" :key="room.externalRoomId">
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="room-chip"
|
2026-09-30 14:58:44 +03:30
|
|
|
|
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned, muted: room.muted }"
|
2026-09-29 10:34:01 +03:30
|
|
|
|
@click="openRoom(room)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="avatar-ring">
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar v-bind="roomAvatarProps(room)" :name="roomTitle(room)" :size="40" />
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</span>
|
|
|
|
|
|
<span class="room-meta">
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<strong>
|
|
|
|
|
|
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<UiIcon v-if="room.muted" name="eye-off" :size="12" class="room-mute-ic" />
|
2026-09-30 10:40:29 +03:30
|
|
|
|
{{ roomTitle(room) }}
|
|
|
|
|
|
</strong>
|
2026-09-30 13:01:42 +03:30
|
|
|
|
<small :class="{ 'room-typing': Boolean(roomTypingPreview(room.externalRoomId)) }">{{
|
|
|
|
|
|
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 21:26:07 +03:30
|
|
|
|
<template v-if="selectMode">
|
|
|
|
|
|
<button type="button" class="back-btn show" aria-label="انصراف" @click="exitSelectMode">
|
|
|
|
|
|
<UiIcon name="x" :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="stage-titles">
|
|
|
|
|
|
<h2>{{ toFaDigits(selectedCount) }} انتخابشده</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="select-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act"
|
|
|
|
|
|
title="ارسال برای دیگری"
|
|
|
|
|
|
:disabled="!selectedCount"
|
|
|
|
|
|
@click="openForwardPicker()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="send" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act danger"
|
|
|
|
|
|
title="حذف"
|
|
|
|
|
|
:disabled="!selectedCount"
|
|
|
|
|
|
@click="deleteSelected"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="trash" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<template v-else-if="chatSearchOpen">
|
|
|
|
|
|
<button type="button" class="back-btn show" aria-label="بستن جستجو" @click="closeChatSearch">
|
|
|
|
|
|
<UiIcon name="arrow-right" :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<label class="chat-search-field">
|
|
|
|
|
|
<UiIcon name="search" :size="16" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="chatSearchQuery"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
placeholder="جستجو در گفتگو…"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
autofocus
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="chat-search-nav">
|
|
|
|
|
|
<span class="chat-search-count">
|
|
|
|
|
|
{{
|
|
|
|
|
|
chatSearchHitCount
|
|
|
|
|
|
? `${toFaDigits(chatSearchIndex + 1)}/${toFaDigits(chatSearchHitCount)}`
|
|
|
|
|
|
: '۰'
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act"
|
|
|
|
|
|
title="قبلی"
|
|
|
|
|
|
:disabled="!chatSearchHitCount"
|
|
|
|
|
|
@click="goSearchHit(-1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="chevron" :size="16" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act search-next"
|
|
|
|
|
|
title="بعدی"
|
|
|
|
|
|
:disabled="!chatSearchHitCount"
|
|
|
|
|
|
@click="goSearchHit(1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="chevron" :size="16" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
<template v-else>
|
|
|
|
|
|
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
|
|
|
|
|
<UiIcon name="arrow-right" :size="20" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="stage-avatar">
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar v-bind="roomAvatarProps(activeRoom)" :name="roomTitle(activeRoom)" :size="36" />
|
2026-09-29 21:26:07 +03:30
|
|
|
|
</span>
|
|
|
|
|
|
<div class="stage-titles">
|
|
|
|
|
|
<h2>{{ roomTitle(activeRoom) }}</h2>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<span class="kind-tag" :data-kind="activeRoom.kind">{{
|
|
|
|
|
|
roomKindLabel(activeRoom.kind)
|
|
|
|
|
|
}}</span>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
</div>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<div class="stage-tools">
|
2026-09-30 11:53:04 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="activeRoom.kind !== 'dm'"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act"
|
|
|
|
|
|
title="اعضا"
|
|
|
|
|
|
@click="openMembersPanel"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="users" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
|
|
|
|
|
|
<UiIcon name="search" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<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>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-act"
|
|
|
|
|
|
:class="{ on: activeRoom.pinned }"
|
|
|
|
|
|
:title="activeRoom.pinned ? 'برداشتن سنجاق گفتگو' : 'سنجاق گفتگو'"
|
|
|
|
|
|
@click="toggleRoomPinned"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="pin" :size="18" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
</template>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
2026-09-30 11:53:04 +03:30
|
|
|
|
<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>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<li
|
|
|
|
|
|
v-for="msg in messages"
|
|
|
|
|
|
:key="msg.id"
|
|
|
|
|
|
class="msg-row"
|
2026-09-30 09:41:49 +03:30
|
|
|
|
:data-msg-id="msg.id"
|
2026-09-29 21:26:07 +03:30
|
|
|
|
:class="{
|
|
|
|
|
|
mine: msg.senderId === chatUserId || msg.optimistic,
|
|
|
|
|
|
selected: isSelected(msg.id),
|
2026-09-30 10:40:29 +03:30
|
|
|
|
'search-hit': activeSearchHitId === msg.id,
|
2026-09-29 21:26:07 +03:30
|
|
|
|
}"
|
2026-09-29 21:06:04 +03:30
|
|
|
|
@contextmenu="openMsgMenu(msg, $event)"
|
2026-09-29 21:26:07 +03:30
|
|
|
|
@click="selectMode ? toggleSelect(msg) : undefined"
|
2026-09-29 10:34:01 +03:30
|
|
|
|
>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="selectMode"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="select-check"
|
|
|
|
|
|
:class="{ on: isSelected(msg.id) }"
|
|
|
|
|
|
:aria-label="isSelected(msg.id) ? 'برداشتن انتخاب' : 'انتخاب'"
|
|
|
|
|
|
@click.stop="toggleSelect(msg)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon v-if="isSelected(msg.id)" name="check-plain" :size="14" />
|
|
|
|
|
|
</button>
|
2026-09-29 21:06:04 +03:30
|
|
|
|
<div
|
|
|
|
|
|
class="bubble"
|
|
|
|
|
|
:class="{
|
2026-09-29 21:36:31 +03:30
|
|
|
|
media: msg.type !== 'text',
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}"
|
|
|
|
|
|
@touchstart.passive="onBubbleTouchStart(msg, $event)"
|
|
|
|
|
|
@touchend="onBubbleTouchEnd"
|
|
|
|
|
|
@touchmove="onBubbleTouchEnd"
|
|
|
|
|
|
@touchcancel="onBubbleTouchEnd"
|
|
|
|
|
|
>
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-if="msg.reply"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="reply-quote"
|
|
|
|
|
|
@click.stop="scrollToMessage(msg.reply)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="reply-quote-label">پاسخ به</span>
|
|
|
|
|
|
<span class="reply-quote-text">{{ replyPreviewText(msg.reply) }}</span>
|
|
|
|
|
|
</button>
|
2026-09-29 21:36:31 +03:30
|
|
|
|
<template v-if="msg.type === 'image'">
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="media-image"
|
|
|
|
|
|
:disabled="!mediaSrc(msg)"
|
|
|
|
|
|
@click="lightboxUrl = mediaSrc(msg)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img :src="mediaSrc(msg)" :alt="msg.fileName || 'تصویر'" />
|
|
|
|
|
|
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-veil">
|
|
|
|
|
|
{{ toFaDigits(msg.uploadProgress) }}٪
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="msg.type === 'voice'">
|
|
|
|
|
|
<audio class="media-audio" :src="mediaSrc(msg)" controls preload="metadata" />
|
|
|
|
|
|
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
|
|
|
|
|
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else-if="msg.type === 'video'">
|
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>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<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>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<button
|
|
|
|
|
|
v-else-if="seg.crm || seg.link"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="msg-link"
|
|
|
|
|
|
:class="{ crm: Boolean(seg.crm) }"
|
|
|
|
|
|
@click.stop="openCrmSeg(seg)"
|
|
|
|
|
|
>
|
2026-09-30 15:23:19 +03:30
|
|
|
|
<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>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<template v-else>{{ seg.text }}</template>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<strong v-else-if="seg.bold">{{ seg.text }}</strong>
|
2026-09-30 15:23:19 +03:30
|
|
|
|
<em v-else-if="seg.italic">{{ seg.text }}</em>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<template v-else>{{ seg.text }}</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</p>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<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>
|
2026-09-29 16:22:37 +03:30
|
|
|
|
<div class="bubble-meta">
|
2026-09-29 21:36:31 +03:30
|
|
|
|
<span v-if="msg.edited" class="edited-mark">ویرایششده</span>
|
2026-09-29 16:22:37 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-30 11:53:04 +03:30
|
|
|
|
<div v-if="typingLabel" class="typing-bar">
|
|
|
|
|
|
<span class="typing-dots" aria-hidden="true"><i /><i /><i /></span>
|
|
|
|
|
|
{{ typingLabel }}
|
|
|
|
|
|
</div>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<div v-if="!canPostInActive" class="channel-readonly">
|
|
|
|
|
|
<UiIcon name="megaphone" :size="18" />
|
|
|
|
|
|
<span>فقط مدیران کانال میتوانند پیام بفرستند</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<form v-else class="composer" @submit.prevent="submitMessage">
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="peer.fullName" :user-id="peer.id" :has-avatar="peer.hasAvatar" :size="28" />
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<span>{{ peer.fullName }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-09-29 21:06:04 +03:30
|
|
|
|
<div v-if="emojiOpen" class="emoji-panel">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="mark in CHAT_EMOJIS"
|
|
|
|
|
|
:key="mark"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="emoji-btn"
|
|
|
|
|
|
@click="insertEmoji(mark)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ mark }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="editingMessageId" class="edit-banner">
|
|
|
|
|
|
<UiIcon name="edit" :size="16" />
|
|
|
|
|
|
<span>ویرایش پیام</span>
|
|
|
|
|
|
<button type="button" class="edit-cancel" @click="cancelEdit(); draft = ''">انصراف</button>
|
|
|
|
|
|
</div>
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<div v-else-if="replyTo" class="edit-banner reply-banner">
|
|
|
|
|
|
<UiIcon name="chat" :size="16" />
|
|
|
|
|
|
<div class="reply-banner-body">
|
|
|
|
|
|
<strong>پاسخ به پیام</strong>
|
|
|
|
|
|
<span>{{ replyPreviewText(replyTo.id) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="button" class="edit-cancel" @click="cancelReply">انصراف</button>
|
|
|
|
|
|
</div>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<div v-if="pendingAttaches.length" class="attach-preview">
|
|
|
|
|
|
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
|
|
|
|
|
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
|
|
|
|
|
<video v-else-if="item.kind === 'video'" :src="item.previewUrl" muted />
|
|
|
|
|
|
<span v-else class="attach-chip-label">
|
|
|
|
|
|
<UiIcon :name="item.kind === 'voice' ? 'mic' : 'document'" :size="14" />
|
|
|
|
|
|
{{ item.name }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button type="button" class="attach-remove" aria-label="حذف" @click="removePending(item.id)">
|
|
|
|
|
|
<UiIcon name="x" :size="12" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="recording" class="rec-bar">
|
|
|
|
|
|
<span class="rec-dot" />
|
|
|
|
|
|
در حال ضبط {{ toFaDigits(recordLabel()) }}
|
|
|
|
|
|
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
|
|
|
|
|
|
</div>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<div class="fmt-bar">
|
2026-09-30 15:23:19 +03:30
|
|
|
|
<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
|
2026-09-30 14:58:44 +03:30
|
|
|
|
</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>
|
2026-09-30 16:30:41 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="fmt-btn fmt-ai"
|
|
|
|
|
|
:title="brand.askLabelFa"
|
|
|
|
|
|
@click="openAiAssist"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="sparkle" :size="14" />
|
|
|
|
|
|
</button>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
</div>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<div class="composer-row">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref="fileInputEl"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
class="sr-only"
|
|
|
|
|
|
multiple
|
2026-10-04 11:33:48 +03:30
|
|
|
|
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar,.txt,.csv"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
@change="onFilePicked"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
2026-09-29 21:06:04 +03:30
|
|
|
|
v-if="showMicInsteadOfSend"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
type="button"
|
2026-09-29 21:06:04 +03:30
|
|
|
|
class="tool-btn mic-btn"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
:class="{ on: recording }"
|
2026-09-29 20:18:17 +03:30
|
|
|
|
title="ضبط پیام صوتی"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
:disabled="sending"
|
|
|
|
|
|
@click="toggleVoiceRecord"
|
|
|
|
|
|
>
|
2026-09-29 21:06:04 +03:30
|
|
|
|
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
|
2026-09-29 17:22:34 +03:30
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
2026-09-29 21:06:04 +03:30
|
|
|
|
v-else
|
2026-09-29 17:22:34 +03:30
|
|
|
|
type="submit"
|
2026-09-29 20:18:17 +03:30
|
|
|
|
class="send-btn"
|
2026-09-29 21:06:04 +03:30
|
|
|
|
:disabled="sending || recording || !canSend"
|
2026-09-29 17:22:34 +03:30
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="send" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 09:19:36 +03:30
|
|
|
|
<div class="input-shell">
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
ref="draftInputEl"
|
|
|
|
|
|
v-model="draft"
|
|
|
|
|
|
class="composer-input"
|
|
|
|
|
|
rows="1"
|
|
|
|
|
|
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
|
|
|
|
|
|
autocomplete="off"
|
2026-09-30 10:04:54 +03:30
|
|
|
|
enterkeyhint="enter"
|
2026-09-30 09:19:36 +03:30
|
|
|
|
:disabled="sending || recording"
|
|
|
|
|
|
@focus="emojiOpen = false"
|
2026-09-30 10:40:29 +03:30
|
|
|
|
@input="onDraftInput"
|
|
|
|
|
|
@click="onDraftInput"
|
|
|
|
|
|
@keyup="onDraftInput"
|
2026-09-30 09:19:36 +03:30
|
|
|
|
@keydown="onComposerKeydown"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="emoji-toggle"
|
|
|
|
|
|
title="ایموجی"
|
|
|
|
|
|
:class="{ on: emojiOpen }"
|
|
|
|
|
|
:disabled="sending || recording"
|
|
|
|
|
|
@click="emojiOpen = !emojiOpen"
|
|
|
|
|
|
>
|
|
|
|
|
|
😊
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="tool-btn attach-btn"
|
|
|
|
|
|
title="پیوست"
|
|
|
|
|
|
:disabled="sending || recording || Boolean(editingMessageId)"
|
|
|
|
|
|
@click="fileInputEl?.click()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="plus" :size="20" />
|
|
|
|
|
|
</button>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
</div>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</form>
|
2026-09-29 21:06:04 +03:30
|
|
|
|
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="msgMenu"
|
|
|
|
|
|
class="msg-menu-backdrop"
|
|
|
|
|
|
@click="msgMenu = null"
|
|
|
|
|
|
@touchstart.passive="msgMenu = null"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="msgMenu && menuMessage()"
|
|
|
|
|
|
ref="msgMenuEl"
|
|
|
|
|
|
class="msg-menu"
|
|
|
|
|
|
:class="{ sheet: msgMenu.sheet }"
|
|
|
|
|
|
:style="
|
|
|
|
|
|
msgMenu.sheet
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: { top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }
|
|
|
|
|
|
"
|
2026-09-29 21:26:07 +03:30
|
|
|
|
>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<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>
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<button type="button" @click="startReplyMessage(menuMessage()!)">
|
|
|
|
|
|
<UiIcon name="chat" :size="16" />
|
|
|
|
|
|
پاسخ
|
|
|
|
|
|
</button>
|
2026-09-30 10:40:29 +03:30
|
|
|
|
<button type="button" @click="togglePinMessage(menuMessage()!)">
|
|
|
|
|
|
<UiIcon name="pin" :size="16" />
|
|
|
|
|
|
{{
|
|
|
|
|
|
pinnedMessage?.messageId === menuMessage()?.id
|
|
|
|
|
|
? 'برداشتن سنجاق'
|
|
|
|
|
|
: 'سنجاق کردن'
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<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>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<button type="button" @click="openConvertToActivity(menuMessage()!)">
|
|
|
|
|
|
<UiIcon name="check" :size="16" />
|
|
|
|
|
|
تبدیل به فعالیت
|
|
|
|
|
|
</button>
|
2026-09-30 09:41:49 +03:30
|
|
|
|
<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>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showNewDm"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<div class="modal-card wide">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewDm = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<h3>گفتگوی جدید</h3>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showNewDm = false">انصراف</button>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<button type="button" class="primary" :disabled="!dmPeerId" @click="startDm">شروع</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showNewGroup"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="modal-card wide member-picker-modal">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewGroup = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
<h3>گروه جدید</h3>
|
|
|
|
|
|
<input v-model="groupName" type="text" placeholder="نام گروه" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="22" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showNewGroup = false">انصراف</button>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="primary"
|
|
|
|
|
|
:disabled="!groupName.trim() || !groupMemberIds.length"
|
|
|
|
|
|
@click="startGroup"
|
|
|
|
|
|
>
|
|
|
|
|
|
ساخت گروه
|
|
|
|
|
|
</button>
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showNewChannel"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="modal-card wide member-picker-modal">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showNewChannel = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<h3>کانال جدید</h3>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<p class="hint channel-hint">
|
|
|
|
|
|
کانال یکطرفه است؛ فقط شما و مدیران سازمان میتوانند پیام بفرستند.
|
|
|
|
|
|
</p>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
<input v-model="channelName" type="text" placeholder="نام کانال" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="22" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<span class="peer-row-meta">
|
|
|
|
|
|
<strong>{{ p.fullName }}</strong>
|
|
|
|
|
|
<small>{{ channelMemberIds.includes(p.id) ? 'انتخابشده' : 'برای افزودن بزنید' }}</small>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="peer-tick" aria-hidden="true">
|
|
|
|
|
|
<UiIcon v-if="channelMemberIds.includes(p.id)" name="check-plain" :size="14" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<p v-if="!filteredPeers.length" class="peer-empty">همکاری پیدا نشد</p>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showNewChannel = false">انصراف</button>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="primary"
|
|
|
|
|
|
:disabled="!channelName.trim() || !channelMemberIds.length"
|
|
|
|
|
|
@click="startChannel"
|
|
|
|
|
|
>
|
|
|
|
|
|
ساخت کانال
|
|
|
|
|
|
</button>
|
2026-09-30 11:08:33 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showActivityFromMsg"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="modal-card activity-sheet">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(closeActivityFromMsg)()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 11:24:58 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showCrmLink"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<div class="modal-card wide">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showCrmLink = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 14:58:44 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showAiAssist"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
2026-09-30 16:30:41 +03:30
|
|
|
|
<div class="modal-card wide ai-assist-sheet">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showAiAssist = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 16:30:41 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showMembers"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="modal-card wide members-sheet member-picker-modal">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showMembers = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-30 11:53:04 +03:30
|
|
|
|
<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">
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar :name="m.fullName" :user-id="m.userId" :has-avatar="m.hasAvatar" :size="40" />
|
2026-09-30 11:53:04 +03:30
|
|
|
|
<span class="peer-row-meta">
|
|
|
|
|
|
<strong>{{ m.fullName }}{{ m.isMe ? ' (شما)' : '' }}</strong>
|
|
|
|
|
|
<small>{{ m.role === 'admin' ? 'ادمین' : 'عضو' }}</small>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div v-if="canManageActiveMembers && !m.isMe" class="member-acts">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="member-act"
|
|
|
|
|
|
:disabled="membersBusy"
|
|
|
|
|
|
@click="toggleMemberAdmin(m)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ m.role === 'admin' ? 'برداشتن ادمین' : 'ادمین کردن' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="member-act danger"
|
|
|
|
|
|
:disabled="membersBusy"
|
|
|
|
|
|
@click="removeRoomMember(m.userId)"
|
|
|
|
|
|
>
|
|
|
|
|
|
حذف
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-if="!roomMembers.length" class="peer-empty">عضوی ثبت نشده</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" @click="showMembers = false">بستن</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
|
2026-10-04 12:12:05 +03:30
|
|
|
|
<dialog
|
|
|
|
|
|
v-if="showForward"
|
|
|
|
|
|
class="modal"
|
|
|
|
|
|
open
|
|
|
|
|
|
>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
<div class="modal-card">
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<button type="button" class="modal-x" aria-label="???? (Esc)" title="???? (Esc)" @click="(() => (showForward = false))()">
|
|
|
|
|
|
<UiIcon name="x" :size="18" />
|
|
|
|
|
|
</button>
|
2026-09-29 21:26:07 +03:30
|
|
|
|
<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)"
|
|
|
|
|
|
>
|
2026-10-06 14:23:59 +03:30
|
|
|
|
<UserAvatar v-bind="roomAvatarProps(room)" :name="roomTitle(room)" :size="36" />
|
2026-09-29 21:26:07 +03:30
|
|
|
|
<span>{{ roomTitle(room) }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button type="button" :disabled="forwardBusy" @click="showForward = false">انصراف</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dialog>
|
|
|
|
|
|
|
2026-10-07 15:23:14 +03:30
|
|
|
|
<dialog v-if="lightboxUrl" class="lightbox" open>
|
2026-09-29 17:22:34 +03:30
|
|
|
|
<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;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.rail-filters {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0 0.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-search {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
padding: 0.4rem 0.65rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(148, 163, 184, 0.12);
|
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-search input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-search input::placeholder {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-tabs {
|
|
|
|
|
|
display: grid;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
|
gap: 0.2rem;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
padding: 0.2rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(148, 163, 184, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-tabs button {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border-radius: 9px;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
padding: 0.35rem 0.15rem;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
font: inherit;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
font-size: 0.72rem;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.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 {
|
2026-09-30 10:40:29 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.3rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.room-pin-ic {
|
|
|
|
|
|
color: #0d9488;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
2026-09-30 14:58:44 +03:30
|
|
|
|
.room-mute-ic {
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 13:01:42 +03:30
|
|
|
|
.room-meta small.room-typing {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
2026-09-30 14:58:44 +03:30
|
|
|
|
.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;
|
2026-09-30 15:23:19 +03:30
|
|
|
|
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;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
border-bottom: 1px solid var(--line);
|
2026-09-30 15:23:19 +03:30
|
|
|
|
margin: 0 0 0.15rem;
|
|
|
|
|
|
overflow: hidden;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
.react-quick-btn {
|
2026-09-30 15:23:19 +03:30
|
|
|
|
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;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
cursor: pointer;
|
2026-09-30 15:23:19 +03:30
|
|
|
|
padding: 0 !important;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
border-radius: 8px;
|
2026-09-30 15:23:19 +03:30
|
|
|
|
display: grid !important;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
color: inherit !important;
|
|
|
|
|
|
gap: 0 !important;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.react-quick-btn:hover,
|
|
|
|
|
|
.react-quick-btn:active {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12) !important;
|
2026-09-30 14:58:44 +03:30
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 16:30:41 +03:30
|
|
|
|
.fmt-btn.fmt-ai {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.msg-text em {
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
2026-09-30 14:58:44 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-29 21:26:07 +03:30
|
|
|
|
.back-btn.show {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.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
|
|
|
|
}
|
2026-09-29 21:26:07 +03:30
|
|
|
|
.select-actions {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.select-act {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
width: 2.35rem;
|
|
|
|
|
|
height: 2.35rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.select-act.danger {
|
|
|
|
|
|
color: #dc2626;
|
|
|
|
|
|
}
|
|
|
|
|
|
.select-act:disabled {
|
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.select-act.on {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12);
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stage-tools {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-field {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
padding: 0.4rem 0.7rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: rgba(148, 163, 184, 0.14);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-field input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-nav {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.1rem;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-count {
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
min-width: 2.4rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-nav .select-act svg {
|
|
|
|
|
|
transform: rotate(-90deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
.chat-search-nav .search-next svg {
|
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
.pin-banner {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
padding: 0.45rem 0.75rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
background: linear-gradient(90deg, rgba(13, 148, 136, 0.1), rgba(99, 102, 241, 0.06));
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pin-banner-main {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pin-banner-text {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pin-banner-x {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
width: 1.75rem;
|
|
|
|
|
|
height: 1.75rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-row.search-hit .bubble {
|
|
|
|
|
|
outline: 2px solid rgba(13, 148, 136, 0.55);
|
|
|
|
|
|
outline-offset: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-text {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mention {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
padding: 0 0.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.search-mark {
|
|
|
|
|
|
background: #fde68a;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
|
padding: 0 0.05rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.mention-sheet {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
|
max-height: 220px;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
margin-bottom: 0.35rem;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mention-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.55rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
}
|
|
|
|
|
|
.mention-item.active,
|
|
|
|
|
|
.mention-item:hover {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.08);
|
|
|
|
|
|
}
|
2026-09-29 21:26:07 +03:30
|
|
|
|
.forward-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
max-height: 280px;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
margin: 0.75rem 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.forward-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.65rem;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
.forward-item:hover,
|
|
|
|
|
|
.forward-item:active {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
.forward-item:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
2026-09-30 11:08:33 +03:30
|
|
|
|
.kind-tag[data-kind='dm'] {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12);
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
.kind-tag[data-kind='group'] {
|
|
|
|
|
|
background: rgba(99, 102, 241, 0.12);
|
|
|
|
|
|
color: #4338ca;
|
|
|
|
|
|
}
|
|
|
|
|
|
.kind-tag[data-kind='channel'] {
|
|
|
|
|
|
background: rgba(245, 158, 11, 0.16);
|
|
|
|
|
|
color: #b45309;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.channel-readonly {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.85rem 1rem calc(0.85rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
|
background: #fff7ed;
|
|
|
|
|
|
color: #9a3412;
|
|
|
|
|
|
font-size: 0.86rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
text-align: center;
|
2026-09-30 11:53:04 +03:30
|
|
|
|
}
|
|
|
|
|
|
.typing-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.35rem 1rem;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.06);
|
|
|
|
|
|
border-top: 1px solid rgba(13, 148, 136, 0.12);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.typing-dots {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
gap: 3px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.typing-dots i {
|
|
|
|
|
|
width: 5px;
|
|
|
|
|
|
height: 5px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: #0d9488;
|
|
|
|
|
|
animation: typingPulse 1.2s infinite ease-in-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
.typing-dots i:nth-child(2) {
|
|
|
|
|
|
animation-delay: 0.15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.typing-dots i:nth-child(3) {
|
|
|
|
|
|
animation-delay: 0.3s;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes typingPulse {
|
|
|
|
|
|
0%,
|
|
|
|
|
|
80%,
|
|
|
|
|
|
100% {
|
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
40% {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.history-loading,
|
|
|
|
|
|
.history-hint {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
padding: 0.35rem 0.5rem 0.75rem;
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-add-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-add-row select {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
padding: 0.55rem 0.65rem;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-add-row .primary {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: #0d9488;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 0.55rem 0.85rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-static {
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-acts {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-act {
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 0.25rem 0.45rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
}
|
|
|
|
|
|
.member-act.danger {
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.members-list {
|
|
|
|
|
|
max-height: 280px;
|
2026-09-30 11:08:33 +03:30
|
|
|
|
}
|
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;
|
2026-09-29 21:26:07 +03:30
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.msg-row.mine {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
2026-09-29 21:26:07 +03:30
|
|
|
|
.msg-row.selected .bubble {
|
|
|
|
|
|
outline: 2px solid rgba(13, 148, 136, 0.45);
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-stream.selecting .msg-row {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.select-check {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
width: 1.35rem;
|
|
|
|
|
|
height: 1.35rem;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
border: 2px solid #94a3b8;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
margin-bottom: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.select-check.on {
|
|
|
|
|
|
border-color: #0d9488;
|
|
|
|
|
|
background: #0d9488;
|
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.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 21:06:04 +03:30
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border-top: 1px solid var(--line);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
background: #f0f2f5;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
flex-shrink: 0;
|
2026-09-30 10:40:29 +03:30
|
|
|
|
position: relative;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.composer-row {
|
|
|
|
|
|
display: flex;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
align-items: flex-end;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
min-width: 0;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
|
.input-shell {
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
display: flex;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
align-items: flex-end;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 22px;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
padding: 0.35rem 0.75rem;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
min-height: 2.75rem;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.composer-input {
|
|
|
|
|
|
flex: 1 1 auto;
|
|
|
|
|
|
min-width: 0;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border: none;
|
|
|
|
|
|
outline: none;
|
|
|
|
|
|
border-radius: 18px;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
padding: 0.35rem 0;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
font: inherit;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
background: transparent;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
resize: none;
|
|
|
|
|
|
max-height: 7.5rem;
|
|
|
|
|
|
line-height: 1.45;
|
|
|
|
|
|
field-sizing: content;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}
|
|
|
|
|
|
.emoji-toggle {
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
border: none;
|
2026-09-30 09:19:36 +03:30
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
width: 2.75rem;
|
|
|
|
|
|
height: 2.75rem;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 1.15rem;
|
|
|
|
|
|
line-height: 1;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.emoji-toggle.on {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12);
|
2026-09-30 09:19:36 +03:30
|
|
|
|
border-color: rgba(13, 148, 136, 0.35);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}
|
|
|
|
|
|
.emoji-panel {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
|
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
max-height: 9.5rem;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 0.45rem;
|
|
|
|
|
|
border-radius: 14px;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
background: #fff;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.emoji-btn {
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
font-size: 1.35rem;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
padding: 0.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.emoji-btn:hover,
|
|
|
|
|
|
.emoji-btn:active {
|
|
|
|
|
|
background: #f1f5f9;
|
|
|
|
|
|
}
|
|
|
|
|
|
.edit-banner {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
padding: 0.45rem 0.65rem;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.1);
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.edit-cancel {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-weight: 600;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.tool-btn,
|
|
|
|
|
|
.send-btn {
|
|
|
|
|
|
flex: 0 0 auto;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
width: 2.75rem;
|
|
|
|
|
|
height: 2.75rem;
|
|
|
|
|
|
min-width: 2.75rem;
|
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 {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
}
|
|
|
|
|
|
.tool-btn.attach-btn,
|
|
|
|
|
|
.tool-btn.mic-btn {
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
color: #334155;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
}
|
|
|
|
|
|
.tool-btn.on {
|
|
|
|
|
|
border-color: #ef4444;
|
|
|
|
|
|
color: #ef4444;
|
|
|
|
|
|
background: rgba(239, 68, 68, 0.08);
|
|
|
|
|
|
}
|
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 21:06:04 +03:30
|
|
|
|
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.28);
|
|
|
|
|
|
}
|
|
|
|
|
|
.bubble.deleted {
|
|
|
|
|
|
opacity: 0.72;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
.deleted-text {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.edited-mark {
|
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-menu-backdrop {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
z-index: 1200;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.28);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}
|
|
|
|
|
|
.msg-menu {
|
|
|
|
|
|
position: fixed;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
z-index: 1210;
|
|
|
|
|
|
min-width: 11rem;
|
|
|
|
|
|
max-width: min(18rem, calc(100vw - 1.5rem));
|
2026-09-30 15:23:19 +03:30
|
|
|
|
width: max-content;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
|
padding: 0.35rem;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
2026-09-30 15:23:19 +03:30
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
}
|
|
|
|
|
|
.msg-menu.sheet {
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
right: auto;
|
|
|
|
|
|
top: auto;
|
|
|
|
|
|
bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
|
transform: translateX(-50%);
|
|
|
|
|
|
width: min(22rem, calc(100vw - 1.5rem));
|
|
|
|
|
|
max-width: none;
|
|
|
|
|
|
padding: 0.45rem;
|
|
|
|
|
|
border-radius: 18px;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.msg-menu > button {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
2026-09-30 09:41:49 +03:30
|
|
|
|
padding: 0.65rem 0.75rem;
|
2026-09-29 21:06:04 +03:30
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
color: #0f172a;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.msg-menu > button:hover,
|
|
|
|
|
|
.msg-menu > button:active {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
2026-09-30 15:23:19 +03:30
|
|
|
|
.msg-menu > button.danger {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
color: #dc2626;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
}
|
2026-09-30 09:41:49 +03:30
|
|
|
|
.reply-quote {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin: 0 0 0.4rem;
|
|
|
|
|
|
padding: 0.4rem 0.55rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-inline-start: 3px solid #0d9488;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.05);
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reply-quote-label {
|
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reply-quote-text {
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
color: #475569;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reply-banner-body {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.1rem;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reply-banner-body strong {
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.reply-banner-body span {
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
.msg-row.flash .bubble {
|
|
|
|
|
|
outline: 2px solid rgba(13, 148, 136, 0.55);
|
|
|
|
|
|
transition: outline-color 0.2s ease;
|
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
|
.attach-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.45rem;
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
padding-bottom: 0.15rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip.image,
|
|
|
|
|
|
.attach-chip.video {
|
|
|
|
|
|
width: 72px;
|
|
|
|
|
|
height: 72px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip img,
|
|
|
|
|
|
.attach-chip video {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-chip-label {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
max-width: 160px;
|
|
|
|
|
|
padding: 0.55rem 0.7rem;
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
.attach-remove {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 4px;
|
|
|
|
|
|
left: 4px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
width: 1.35rem;
|
|
|
|
|
|
height: 1.35rem;
|
|
|
|
|
|
background: rgba(15, 23, 42, 0.7);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-bar {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-dot {
|
|
|
|
|
|
width: 0.55rem;
|
|
|
|
|
|
height: 0.55rem;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: #ef4444;
|
|
|
|
|
|
animation: pulse 1s infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rec-stop {
|
|
|
|
|
|
margin-inline-start: auto;
|
|
|
|
|
|
border: 1px solid #fecaca;
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 0.25rem 0.65rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
|
50% {
|
|
|
|
|
|
opacity: 0.35;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.sr-only {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 1px;
|
|
|
|
|
|
height: 1px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
margin: -1px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
clip: rect(0, 0, 0, 0);
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
}
|
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;
|
|
|
|
|
|
}
|
2026-10-07 15:23:14 +03:30
|
|
|
|
.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; }
|
2026-09-29 10:34:01 +03:30
|
|
|
|
.modal-card {
|
2026-10-07 15:23:14 +03:30
|
|
|
|
position: relative;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
|
width: min(400px, 100%);
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.modal-card.wide {
|
2026-09-30 11:24:58 +03:30
|
|
|
|
width: min(440px, 100%);
|
2026-10-04 12:12:05 +03:30
|
|
|
|
max-height: min(88vh, 720px);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
overflow: auto;
|
|
|
|
|
|
}
|
2026-10-04 12:12:05 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 11:24:58 +03:30
|
|
|
|
.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 {
|
2026-09-29 10:34:01 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
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;
|
2026-10-04 12:12:05 +03:30
|
|
|
|
max-height: min(48vh, 360px);
|
2026-09-30 11:24:58 +03:30
|
|
|
|
overflow: auto;
|
2026-10-04 12:12:05 +03:30
|
|
|
|
overscroll-behavior: contain;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
padding: 0.15rem;
|
|
|
|
|
|
margin: 0 -0.15rem;
|
2026-10-04 12:12:05 +03:30
|
|
|
|
-webkit-overflow-scrolling: touch;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
}
|
|
|
|
|
|
.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%);
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.modal-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 0.5rem;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
margin-top: 0.25rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
2026-09-30 11:24:58 +03:30
|
|
|
|
.modal-actions button {
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
padding: 0.5rem 0.9rem;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
cursor: pointer;
|
2026-09-30 11:24:58 +03:30
|
|
|
|
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;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.rail-filters {
|
|
|
|
|
|
padding: 0 0.5rem 0.45rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.rail-tabs button.active {
|
|
|
|
|
|
background: rgba(13, 148, 136, 0.12);
|
|
|
|
|
|
box-shadow: none;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 10:40:29 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-09-30 11:24:58 +03:30
|
|
|
|
.activity-sheet,
|
|
|
|
|
|
.modal-card.wide {
|
|
|
|
|
|
width: min(100%, 100vw - 1.5rem);
|
|
|
|
|
|
border-radius: 18px 18px 14px 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.peer-list {
|
|
|
|
|
|
max-height: 38vh;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
.msg-stream {
|
|
|
|
|
|
padding: 0.75rem 0.7rem 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.bubble {
|
|
|
|
|
|
max-width: min(300px, 82%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.composer {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
padding: 0.4rem 0.45rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
|
background: #f0f2f5;
|
|
|
|
|
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05);
|
2026-09-29 20:18:17 +03:30
|
|
|
|
}
|
|
|
|
|
|
.tool-btn,
|
|
|
|
|
|
.send-btn {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
width: 2.85rem;
|
|
|
|
|
|
height: 2.85rem;
|
|
|
|
|
|
min-width: 2.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.input-shell {
|
|
|
|
|
|
min-height: 2.85rem;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
}
|
|
|
|
|
|
.composer-input {
|
2026-09-29 21:06:04 +03:30
|
|
|
|
padding: 0.6rem 0.3rem;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
|
.emoji-panel {
|
|
|
|
|
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
|
|
|
|
|
max-height: 11rem;
|
|
|
|
|
|
}
|
2026-09-29 20:18:17 +03:30
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
</style>
|
2026-09-29 20:18:17 +03:30
|
|
|
|
|