Add mentions, pin messages and conversations, and search within chats
This commit is contained in:
@@ -5,16 +5,19 @@ import { api } from '@/lib/api';
|
||||
import type {
|
||||
ConversationAccessDto,
|
||||
ConversationPeerDto,
|
||||
ConversationPinnedMessageDto,
|
||||
ConversationRoomDto,
|
||||
} from '@mento/shared';
|
||||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
classifyChatFile,
|
||||
onMessagePinEvent,
|
||||
onRoomLiveEvent,
|
||||
previewLabelForMessage,
|
||||
useOrgChat,
|
||||
type ChatDelivery,
|
||||
type ChatMessage,
|
||||
type MessagePinEvent,
|
||||
type RoomLiveEvent,
|
||||
} from '@/lib/useOrgChat';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
@@ -72,6 +75,8 @@ const {
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
pinChatMessage,
|
||||
unpinChatMessage,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -80,6 +85,7 @@ const {
|
||||
} = useOrgChat();
|
||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||
let stopRoomLive: (() => boolean) | undefined;
|
||||
let stopPinLive: (() => boolean) | undefined;
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recordChunks: Blob[] = [];
|
||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -89,6 +95,14 @@ const selectMode = ref(false);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const showForward = ref(false);
|
||||
const forwardBusy = ref(false);
|
||||
const roomListQuery = ref('');
|
||||
const roomListTab = ref<'all' | 'dm' | 'group'>('all');
|
||||
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
|
||||
const chatSearchOpen = ref(false);
|
||||
const chatSearchQuery = ref('');
|
||||
const chatSearchIndex = ref(0);
|
||||
const mentionState = ref<{ start: number; end: number; query: string } | null>(null);
|
||||
const mentionIndex = ref(0);
|
||||
|
||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||
|
||||
@@ -101,18 +115,112 @@ async function scrollToBottom() {
|
||||
|
||||
const sortedRooms = computed(() =>
|
||||
[...rooms.value].sort((a, b) => {
|
||||
const pinA = a.pinned ? 1 : 0;
|
||||
const pinB = b.pinned ? 1 : 0;
|
||||
if (pinA !== pinB) return pinB - pinA;
|
||||
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||||
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredRooms = computed(() => {
|
||||
const q = roomListQuery.value.trim().toLowerCase();
|
||||
return sortedRooms.value.filter((room) => {
|
||||
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
|
||||
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
|
||||
if (!q) return true;
|
||||
const title = roomTitle(room).toLowerCase();
|
||||
const preview = (room.lastMessagePreview || '').toLowerCase();
|
||||
return title.includes(q) || preview.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const mentionCandidates = computed(() => {
|
||||
const state = mentionState.value;
|
||||
if (!state) return [] as ConversationPeerDto[];
|
||||
const q = state.query.trim().toLowerCase();
|
||||
return peers.value
|
||||
.filter((p) => !q || p.fullName.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
const chatSearchHits = computed(() => {
|
||||
const q = chatSearchQuery.value.trim().toLowerCase();
|
||||
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
|
||||
return messages.value.filter((m) => {
|
||||
if (m.deleted) return false;
|
||||
if (m.type === 'text') return (m.content || '').toLowerCase().includes(q);
|
||||
return previewLabelForMessage(m).toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const chatSearchHitCount = computed(() => chatSearchHits.value.length);
|
||||
const activeSearchHitId = computed(() => {
|
||||
const hits = chatSearchHits.value;
|
||||
if (!hits.length) return '';
|
||||
const idx = ((chatSearchIndex.value % hits.length) + hits.length) % hits.length;
|
||||
return hits[idx]?.id ?? '';
|
||||
});
|
||||
|
||||
function roomTitle(room: ConversationRoomDto) {
|
||||
if (room.kind === 'group') return room.name;
|
||||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||||
return peer?.fullName ?? room.name;
|
||||
}
|
||||
|
||||
type TextSeg = { text: string; mention?: boolean; hit?: boolean };
|
||||
|
||||
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
|
||||
const raw = String(content || '');
|
||||
if (!raw) return [];
|
||||
const names = [...peers.value]
|
||||
.map((p) => p.fullName.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const mentionRe = names.length
|
||||
? new RegExp(`(@(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')}))`, 'g')
|
||||
: /(@[^\s@]{2,40})/g;
|
||||
|
||||
const parts: TextSeg[] = [];
|
||||
let last = 0;
|
||||
for (const match of raw.matchAll(mentionRe)) {
|
||||
const idx = match.index ?? 0;
|
||||
if (idx > last) parts.push({ text: raw.slice(last, idx) });
|
||||
parts.push({ text: match[0], mention: true });
|
||||
last = idx + match[0].length;
|
||||
}
|
||||
if (last < raw.length) parts.push({ text: raw.slice(last) });
|
||||
|
||||
const q = highlightQuery.trim();
|
||||
if (!q) return parts.length ? parts : [{ text: raw }];
|
||||
|
||||
const out: TextSeg[] = [];
|
||||
const qLower = q.toLowerCase();
|
||||
for (const part of parts) {
|
||||
if (part.mention) {
|
||||
out.push(part);
|
||||
continue;
|
||||
}
|
||||
const text = part.text;
|
||||
const lower = text.toLowerCase();
|
||||
let cursor = 0;
|
||||
let found = lower.indexOf(qLower, cursor);
|
||||
if (found < 0) {
|
||||
out.push(part);
|
||||
continue;
|
||||
}
|
||||
while (found >= 0) {
|
||||
if (found > cursor) out.push({ text: text.slice(cursor, found) });
|
||||
out.push({ text: text.slice(found, found + q.length), hit: true });
|
||||
cursor = found + q.length;
|
||||
found = lower.indexOf(qLower, cursor);
|
||||
}
|
||||
if (cursor < text.length) out.push({ text: text.slice(cursor) });
|
||||
}
|
||||
return out.length ? out : [{ text: raw }];
|
||||
}
|
||||
|
||||
function formatListTime(iso: string | null) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
@@ -145,6 +253,9 @@ function closeMobileChat() {
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
pinnedMessage.value = null;
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
}
|
||||
|
||||
@@ -207,10 +318,12 @@ async function deleteSelected() {
|
||||
if (otherCount) parts.push(`${otherCount} پیام دریافتی فقط برای شما`);
|
||||
if (!window.confirm(`حذف شود؟\n${parts.join('\n')}`)) return;
|
||||
try {
|
||||
const pinnedId = pinnedMessage.value?.messageId;
|
||||
await deleteMessages(
|
||||
activeRoom.value.externalRoomId,
|
||||
list.map((m) => ({ id: m.id, forEveryone: isMineMessage(m) })),
|
||||
);
|
||||
if (pinnedId && list.some((m) => m.id === pinnedId)) pinnedMessage.value = null;
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
@@ -309,6 +422,147 @@ function scrollToMessage(id: string | null | undefined) {
|
||||
window.setTimeout(() => row.classList.remove('flash'), 1200);
|
||||
}
|
||||
|
||||
async function refreshPinnedMessage(roomId: string) {
|
||||
if (!roomId) {
|
||||
pinnedMessage.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<ConversationPinnedMessageDto | null>(
|
||||
'/conversations/messages/pin',
|
||||
{ params: { externalRoomId: roomId } },
|
||||
);
|
||||
pinnedMessage.value = data?.messageId ? data : null;
|
||||
} catch {
|
||||
pinnedMessage.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMessagePin(event: MessagePinEvent) {
|
||||
if (activeRoom.value?.externalRoomId !== event.roomId) return;
|
||||
if (!event.messageId) {
|
||||
pinnedMessage.value = null;
|
||||
return;
|
||||
}
|
||||
pinnedMessage.value = {
|
||||
messageId: event.messageId,
|
||||
preview: event.preview || pinnedMessage.value?.preview || 'پیام سنجاقشده',
|
||||
pinnedByUserId: pinnedMessage.value?.pinnedByUserId || '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function togglePinMessage(msg: ChatMessage) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
const roomId = activeRoom.value.externalRoomId;
|
||||
try {
|
||||
if (pinnedMessage.value?.messageId === msg.id) {
|
||||
await unpinChatMessage(roomId);
|
||||
pinnedMessage.value = null;
|
||||
} else {
|
||||
await pinChatMessage(roomId, msg);
|
||||
pinnedMessage.value = {
|
||||
messageId: msg.id,
|
||||
preview: previewLabelForMessage(msg).slice(0, 240),
|
||||
pinnedByUserId: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'سنجاق پیام ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPinnedMessage() {
|
||||
if (!activeRoom.value) return;
|
||||
try {
|
||||
await unpinChatMessage(activeRoom.value.externalRoomId);
|
||||
pinnedMessage.value = null;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'برداشتن سنجاق ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRoomPinned() {
|
||||
if (!activeRoom.value) return;
|
||||
const room = activeRoom.value;
|
||||
const next = !room.pinned;
|
||||
try {
|
||||
await api.post('/conversations/rooms/pin', {
|
||||
externalRoomId: room.externalRoomId,
|
||||
pinned: next,
|
||||
});
|
||||
const updated = { ...room, pinned: next };
|
||||
activeRoom.value = updated;
|
||||
rooms.value = rooms.value.map((r) =>
|
||||
r.externalRoomId === room.externalRoomId ? { ...r, pinned: next } : r,
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'سنجاق گفتگو ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
function openChatSearch() {
|
||||
chatSearchOpen.value = true;
|
||||
chatSearchQuery.value = '';
|
||||
chatSearchIndex.value = 0;
|
||||
emojiOpen.value = false;
|
||||
mentionState.value = null;
|
||||
msgMenu.value = null;
|
||||
}
|
||||
|
||||
function closeChatSearch() {
|
||||
chatSearchOpen.value = false;
|
||||
chatSearchQuery.value = '';
|
||||
chatSearchIndex.value = 0;
|
||||
}
|
||||
|
||||
function goSearchHit(delta: number) {
|
||||
const total = chatSearchHitCount.value;
|
||||
if (!total) return;
|
||||
chatSearchIndex.value = (chatSearchIndex.value + delta + total) % total;
|
||||
nextTick(() => scrollToMessage(activeSearchHitId.value));
|
||||
}
|
||||
|
||||
function updateMentionState() {
|
||||
const el = draftInputEl.value;
|
||||
if (!el) {
|
||||
mentionState.value = null;
|
||||
return;
|
||||
}
|
||||
const caret = el.selectionStart ?? draft.value.length;
|
||||
const before = draft.value.slice(0, caret);
|
||||
const match = before.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||
if (!match) {
|
||||
mentionState.value = null;
|
||||
return;
|
||||
}
|
||||
const query = match[2] ?? '';
|
||||
const start = caret - query.length - 1;
|
||||
mentionState.value = { start, end: caret, query };
|
||||
mentionIndex.value = 0;
|
||||
}
|
||||
|
||||
function insertMention(peer: ConversationPeerDto) {
|
||||
const state = mentionState.value;
|
||||
if (!state) return;
|
||||
const token = `@${peer.fullName} `;
|
||||
draft.value = draft.value.slice(0, state.start) + token + draft.value.slice(state.end);
|
||||
mentionState.value = null;
|
||||
nextTick(() => {
|
||||
const el = draftInputEl.value;
|
||||
if (!el) return;
|
||||
const pos = state.start + token.length;
|
||||
el.focus();
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
function onDraftInput() {
|
||||
updateMentionState();
|
||||
}
|
||||
|
||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
@@ -324,6 +578,7 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
forEveryone: mine,
|
||||
});
|
||||
if (editingMessageId.value === msg.id) cancelEdit();
|
||||
if (pinnedMessage.value?.messageId === msg.id) pinnedMessage.value = null;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
}
|
||||
@@ -350,6 +605,30 @@ function insertComposerNewline() {
|
||||
}
|
||||
|
||||
function onComposerKeydown(event: KeyboardEvent) {
|
||||
if (mentionState.value && mentionCandidates.value.length) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
mentionIndex.value = (mentionIndex.value + 1) % mentionCandidates.value.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
mentionIndex.value =
|
||||
(mentionIndex.value - 1 + mentionCandidates.value.length) % mentionCandidates.value.length;
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
const peer = mentionCandidates.value[mentionIndex.value];
|
||||
if (peer) insertMention(peer);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
mentionState.value = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key !== 'Enter') return;
|
||||
// موبایل: Enter = خط بعد؛ ارسال فقط با دکمه ارسال
|
||||
if (isMobileComposer()) return;
|
||||
@@ -652,7 +931,10 @@ async function openRoom(room: ConversationRoomDto) {
|
||||
rooms.value = rooms.value.map((r) =>
|
||||
r.externalRoomId === room.externalRoomId ? cleared : r,
|
||||
);
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
await loadMessages(room.externalRoomId);
|
||||
await refreshPinnedMessage(room.externalRoomId);
|
||||
sendRead(room.externalRoomId);
|
||||
void refreshUnreadTotal();
|
||||
}
|
||||
@@ -769,6 +1051,8 @@ watch(activeRoom, () => {
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
closeChatSearch();
|
||||
mentionState.value = null;
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
}
|
||||
@@ -777,23 +1061,36 @@ watch(activeRoom, () => {
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
() => {
|
||||
if (chatSearchOpen.value) return;
|
||||
void scrollToBottom();
|
||||
},
|
||||
);
|
||||
|
||||
watch(loadingMessages, (v) => {
|
||||
if (!v) void scrollToBottom();
|
||||
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));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setConversationsPageOpen(true);
|
||||
stopRoomLive = onRoomLiveEvent(applyRoomLive);
|
||||
stopPinLive = onMessagePinEvent(applyMessagePin);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
setConversationsPageOpen(false);
|
||||
stopRoomLive?.();
|
||||
stopPinLive?.();
|
||||
clearActiveRoom();
|
||||
clearPendingAttaches();
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
@@ -838,19 +1135,65 @@ onUnmounted(() => {
|
||||
گروه
|
||||
</button>
|
||||
</div>
|
||||
<div class="rail-filters">
|
||||
<label class="rail-search">
|
||||
<UiIcon name="search" :size="16" />
|
||||
<input
|
||||
v-model="roomListQuery"
|
||||
type="search"
|
||||
placeholder="جستجوی گفتگو…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<div class="rail-tabs" role="tablist" aria-label="نوع گفتگو">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="roomListTab === 'all'"
|
||||
:class="{ active: roomListTab === 'all' }"
|
||||
@click="roomListTab = 'all'"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="roomListTab === 'dm'"
|
||||
:class="{ active: roomListTab === 'dm' }"
|
||||
@click="roomListTab = 'dm'"
|
||||
>
|
||||
خصوصی
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="roomListTab === 'group'"
|
||||
:class="{ active: roomListTab === 'group' }"
|
||||
@click="roomListTab = 'group'"
|
||||
>
|
||||
گروه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="room-list">
|
||||
<li v-for="room in sortedRooms" :key="room.externalRoomId">
|
||||
<li v-if="!filteredRooms.length" class="rail-empty">
|
||||
{{ roomListQuery.trim() ? 'گفتگویی پیدا نشد' : 'گفتگویی در این بخش نیست' }}
|
||||
</li>
|
||||
<li v-for="room in filteredRooms" :key="room.externalRoomId">
|
||||
<button
|
||||
type="button"
|
||||
class="room-chip"
|
||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
||||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId, pinned: room.pinned }"
|
||||
@click="openRoom(room)"
|
||||
>
|
||||
<span class="avatar-ring">
|
||||
<UserAvatar :name="roomTitle(room)" :size="40" />
|
||||
</span>
|
||||
<span class="room-meta">
|
||||
<strong>{{ roomTitle(room) }}</strong>
|
||||
<strong>
|
||||
<UiIcon v-if="room.pinned" name="pin" :size="12" class="room-pin-ic" />
|
||||
{{ roomTitle(room) }}
|
||||
</strong>
|
||||
<small>{{ roomPreviewText(room) }}</small>
|
||||
</span>
|
||||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||||
@@ -901,6 +1244,48 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="chatSearchOpen">
|
||||
<button type="button" class="back-btn show" aria-label="بستن جستجو" @click="closeChatSearch">
|
||||
<UiIcon name="arrow-right" :size="20" />
|
||||
</button>
|
||||
<label class="chat-search-field">
|
||||
<UiIcon name="search" :size="16" />
|
||||
<input
|
||||
v-model="chatSearchQuery"
|
||||
type="search"
|
||||
placeholder="جستجو در گفتگو…"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<div class="chat-search-nav">
|
||||
<span class="chat-search-count">
|
||||
{{
|
||||
chatSearchHitCount
|
||||
? `${toFaDigits(chatSearchIndex + 1)}/${toFaDigits(chatSearchHitCount)}`
|
||||
: '۰'
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="select-act"
|
||||
title="قبلی"
|
||||
:disabled="!chatSearchHitCount"
|
||||
@click="goSearchHit(-1)"
|
||||
>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="select-act search-next"
|
||||
title="بعدی"
|
||||
:disabled="!chatSearchHitCount"
|
||||
@click="goSearchHit(1)"
|
||||
>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||
<UiIcon name="arrow-right" :size="20" />
|
||||
@@ -912,9 +1297,45 @@ onUnmounted(() => {
|
||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||
</div>
|
||||
<div class="stage-tools">
|
||||
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
|
||||
<UiIcon name="search" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="select-act"
|
||||
:class="{ on: activeRoom.pinned }"
|
||||
:title="activeRoom.pinned ? 'برداشتن سنجاق گفتگو' : 'سنجاق گفتگو'"
|
||||
@click="toggleRoomPinned"
|
||||
>
|
||||
<UiIcon name="pin" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pinnedMessage && !chatSearchOpen && !selectMode"
|
||||
class="pin-banner"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pin-banner-main"
|
||||
@click="scrollToMessage(pinnedMessage.messageId)"
|
||||
>
|
||||
<UiIcon name="pin" :size="16" />
|
||||
<span class="pin-banner-text">{{ pinnedMessage.preview || 'پیام سنجاقشده' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pin-banner-x"
|
||||
aria-label="برداشتن سنجاق"
|
||||
@click="clearPinnedMessage"
|
||||
>
|
||||
<UiIcon name="x" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
|
||||
<li
|
||||
@@ -925,6 +1346,7 @@ onUnmounted(() => {
|
||||
:class="{
|
||||
mine: msg.senderId === chatUserId || msg.optimistic,
|
||||
selected: isSelected(msg.id),
|
||||
'search-hit': activeSearchHitId === msg.id,
|
||||
}"
|
||||
@contextmenu="openMsgMenu(msg, $event)"
|
||||
@click="selectMode ? toggleSelect(msg) : undefined"
|
||||
@@ -1015,7 +1437,19 @@ onUnmounted(() => {
|
||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</template>
|
||||
<p v-else>{{ msg.content }}</p>
|
||||
<p v-else class="msg-text">
|
||||
<template
|
||||
v-for="(seg, si) in messageTextSegments(
|
||||
msg.content,
|
||||
chatSearchOpen ? chatSearchQuery : '',
|
||||
)"
|
||||
:key="si"
|
||||
>
|
||||
<span v-if="seg.mention" class="mention">{{ seg.text }}</span>
|
||||
<mark v-else-if="seg.hit" class="search-mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</p>
|
||||
<div class="bubble-meta">
|
||||
<span v-if="msg.edited" class="edited-mark">ویرایششده</span>
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
@@ -1037,6 +1471,19 @@ onUnmounted(() => {
|
||||
</ul>
|
||||
|
||||
<form class="composer" @submit.prevent="submitMessage">
|
||||
<div v-if="mentionState && mentionCandidates.length" class="mention-sheet">
|
||||
<button
|
||||
v-for="(peer, idx) in mentionCandidates"
|
||||
:key="peer.id"
|
||||
type="button"
|
||||
class="mention-item"
|
||||
:class="{ active: idx === mentionIndex }"
|
||||
@click="insertMention(peer)"
|
||||
>
|
||||
<UserAvatar :name="peer.fullName" :size="28" />
|
||||
<span>{{ peer.fullName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="emojiOpen" class="emoji-panel">
|
||||
<button
|
||||
v-for="mark in CHAT_EMOJIS"
|
||||
@@ -1118,6 +1565,9 @@ onUnmounted(() => {
|
||||
enterkeyhint="enter"
|
||||
:disabled="sending || recording"
|
||||
@focus="emojiOpen = false"
|
||||
@input="onDraftInput"
|
||||
@click="onDraftInput"
|
||||
@keyup="onDraftInput"
|
||||
@keydown="onComposerKeydown"
|
||||
/>
|
||||
</div>
|
||||
@@ -1165,6 +1615,14 @@ onUnmounted(() => {
|
||||
<UiIcon name="chat" :size="16" />
|
||||
پاسخ
|
||||
</button>
|
||||
<button type="button" @click="togglePinMessage(menuMessage()!)">
|
||||
<UiIcon name="pin" :size="16" />
|
||||
{{
|
||||
pinnedMessage?.messageId === menuMessage()?.id
|
||||
? 'برداشتن سنجاق'
|
||||
: 'سنجاق کردن'
|
||||
}}
|
||||
</button>
|
||||
<button type="button" @click="enterSelectMode(menuMessage()!)">
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
انتخاب
|
||||
@@ -1370,6 +1828,66 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rail-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.rail-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.rail-search input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.rail-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.rail-tabs button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 9px;
|
||||
padding: 0.35rem 0.25rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.rail-tabs button.active {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.rail-empty {
|
||||
padding: 1.25rem 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
@@ -1422,9 +1940,15 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
.room-meta strong {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.room-pin-ic {
|
||||
color: #0d9488;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.room-meta small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
@@ -1529,6 +2053,148 @@ onUnmounted(() => {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.select-act.on {
|
||||
background: rgba(13, 148, 136, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
.stage-tools {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-search-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: var(--muted);
|
||||
}
|
||||
.chat-search-field input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.chat-search-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-search-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
min-width: 2.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
.chat-search-nav .select-act svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.chat-search-nav .search-next svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.pin-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, rgba(13, 148, 136, 0.1), rgba(99, 102, 241, 0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pin-banner-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.pin-banner-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pin-banner-x {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-row.search-hit .bubble {
|
||||
outline: 2px solid rgba(13, 148, 136, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.msg-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mention {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
background: rgba(13, 148, 136, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.search-mark {
|
||||
background: #fde68a;
|
||||
color: inherit;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.05rem;
|
||||
}
|
||||
.mention-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.35rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.55rem 0.7rem;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
.mention-item.active,
|
||||
.mention-item:hover {
|
||||
background: rgba(13, 148, 136, 0.08);
|
||||
}
|
||||
.forward-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1731,6 +2397,7 @@ onUnmounted(() => {
|
||||
border-top: 1px solid var(--line);
|
||||
background: #f0f2f5;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.composer-row {
|
||||
display: flex;
|
||||
@@ -2248,6 +2915,13 @@ onUnmounted(() => {
|
||||
.rail-actions {
|
||||
padding: 0 0.5rem 0.35rem;
|
||||
}
|
||||
.rail-filters {
|
||||
padding: 0 0.5rem 0.45rem;
|
||||
}
|
||||
.rail-tabs button.active {
|
||||
background: rgba(13, 148, 136, 0.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
.room-list {
|
||||
max-height: none;
|
||||
}
|
||||
@@ -2285,6 +2959,23 @@ onUnmounted(() => {
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.pin-banner {
|
||||
position: sticky;
|
||||
top: 3.25rem;
|
||||
z-index: 2;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.mention-sheet {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
bottom: calc(100% + 0.35rem);
|
||||
max-height: 42vh;
|
||||
z-index: 5;
|
||||
}
|
||||
.composer {
|
||||
position: relative;
|
||||
}
|
||||
.msg-stream {
|
||||
padding: 0.75rem 0.7rem 0.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user