Add mentions, pin messages and conversations, and search within chats

This commit is contained in:
2026-09-30 10:40:29 +03:30
parent 7c4b24fad6
commit f71e78f3a7
9 changed files with 1152 additions and 31 deletions
+5
View File
@@ -31,6 +31,7 @@ defineProps<{
| 'plus'
| 'chevron'
| 'star'
| 'pin'
| 'filter'
| 'funnel'
| 'lightbulb'
@@ -247,6 +248,10 @@ defineProps<{
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'pin'">
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
<path d="M12 20v-5" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
+128 -19
View File
@@ -33,7 +33,49 @@ export type RoomLiveEvent = {
fromSelf: boolean;
};
const PAGE_SIZE = 50;
export type MessagePinEvent = {
roomId: string;
/** null = unpinned */
messageId: string | null;
preview: string;
fromSelf: boolean;
};
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
type PinListener = (event: MessagePinEvent) => void;
const pinListeners = new Set<PinListener>();
export function onMessagePinEvent(listener: PinListener) {
pinListeners.add(listener);
return () => pinListeners.delete(listener);
}
function emitMessagePin(event: MessagePinEvent) {
for (const listener of pinListeners) {
try {
listener(event);
} catch {
// ignore
}
}
}
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
@@ -41,6 +83,9 @@ const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
const HARD_DELETE_MARKER = '__MENTO_DEL__';
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
/** Realtime pin / unpin control messages. */
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
const UNPIN_CMD = '__MENTO_UNPIN__';
function isDeletedContent(content: string): boolean {
const t = content.trim();
@@ -48,7 +93,9 @@ function isDeletedContent(content: string): boolean {
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
t.includes('MENTO_DEL') ||
t === HARD_DELETE_MARKER ||
t.startsWith(HIDE_CMD_PREFIX)
t.startsWith(HIDE_CMD_PREFIX) ||
t.startsWith(PIN_CMD_PREFIX) ||
t === UNPIN_CMD
);
}
@@ -59,6 +106,15 @@ function parseHideCommand(content: string): string | null {
return id || null;
}
/** null = not a pin cmd; '' = unpin; otherwise messageId */
function parsePinCommand(content: string): string | null {
const t = content.trim();
if (t === UNPIN_CMD) return '';
if (!t.startsWith(PIN_CMD_PREFIX)) return null;
const id = t.slice(PIN_CMD_PREFIX.length).trim();
return id || null;
}
function purgeMessageById(id: string) {
const key = String(id || '').trim();
if (!key) return;
@@ -159,23 +215,7 @@ const activeRoomId = ref('');
const unreadTotal = ref(0);
const pageOpen = ref(false);
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
const PAGE_SIZE = 200;
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
@@ -734,6 +774,27 @@ function onSocketMessage(event: MessageEvent) {
void persistHiddenOnServer(roomId, ids, true);
return;
}
const pinTarget = parsePinCommand(purgeContent);
if (pinTarget !== null) {
const roomId = String(
(parsed.room_id as string) ||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
activeRoomId.value,
);
if (purgeId) rememberHidden(roomId, [purgeId]);
if (pinTarget === '') {
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: false });
} else {
const found = messages.value.find((m) => m.id === pinTarget);
emitMessagePin({
roomId,
messageId: pinTarget,
preview: found ? previewLabelForMessage(found) : '',
fromSelf: false,
});
}
return;
}
if (purgeId && isDeletedContent(purgeContent)) {
purgeMessageById(purgeId);
return;
@@ -1271,6 +1332,52 @@ export function useOrgChat() {
}
}
async function pinChatMessage(roomId: string, msg: ChatMessage) {
const messageId = String(msg.id || '').trim();
if (!roomId || !messageId || msg.optimistic) return;
const preview = previewLabelForMessage(msg).slice(0, 240);
await api.post('/conversations/messages/pin', {
externalRoomId: roomId,
messageId,
preview,
});
emitMessagePin({ roomId, messageId, preview, fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${PIN_CMD_PREFIX}${messageId}`,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
async function unpinChatMessage(roomId: string) {
if (!roomId) return;
await api.post('/conversations/messages/unpin', { externalRoomId: roomId });
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: UNPIN_CMD,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
function clearActiveRoom() {
stopHiddenPoll();
activeRoomId.value = '';
@@ -1298,6 +1405,8 @@ export function useOrgChat() {
deleteMessage,
deleteMessages,
forwardMessages,
pinChatMessage,
unpinChatMessage,
sendRead,
ensureConnected,
setConversationsPageOpen,
+697 -6
View File
@@ -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;
}