Adding the typing... indicator, loading older messages, and managing members/admins after creation.

This commit is contained in:
2026-09-30 11:53:04 +03:30
parent b978708487
commit e1d456ad11
9 changed files with 992 additions and 28 deletions
+154 -25
View File
@@ -214,8 +214,47 @@ const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
const unreadTotal = ref(0);
const pageOpen = ref(false);
const hasMoreMessages = ref(false);
const loadingOlderMessages = ref(false);
/** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */
let fetchedRawCount = 0;
const typingMap = ref<Record<string, Array<{ userId: string; at: number }>>>({});
let typingSweepTimer: ReturnType<typeof setInterval> | null = null;
const PAGE_SIZE = 200;
function startTypingSweep() {
if (typingSweepTimer) return;
typingSweepTimer = setInterval(() => {
const now = Date.now();
let changed = false;
const next: Record<string, Array<{ userId: string; at: number }>> = {};
for (const [roomId, list] of Object.entries(typingMap.value)) {
const kept = list.filter((t) => now - t.at < 3500);
if (kept.length) next[roomId] = kept;
if (kept.length !== list.length) changed = true;
}
if (changed) typingMap.value = next;
}, 1000);
}
function applyTypingEvent(roomId: string, userId: string) {
const rid = String(roomId || '').trim();
const uid = String(userId || '').trim();
if (!rid || !uid) return;
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
startTypingSweep();
const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid);
list.push({ userId: uid, at: Date.now() });
typingMap.value = { ...typingMap.value, [rid]: list };
}
function clearTypingForRoom(roomId: string) {
if (!typingMap.value[roomId]) return;
const next = { ...typingMap.value };
delete next[roomId];
typingMap.value = next;
}
const PAGE_SIZE = 40;
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
@@ -732,7 +771,24 @@ function onSocketMessage(event: MessageEvent) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return;
if (type === 'pong' || type === 'ping' || type === 'read') return;
if (type === 'typing') {
const roomId = String(
parsed.room_id ??
parsed.roomId ??
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ??
activeRoomId.value,
);
const userId = String(
parsed.user_id ??
parsed.sender_id ??
parsed.from_user_id ??
parsed.userId ??
'',
);
applyTypingEvent(roomId, userId);
return;
}
if (type === 'seen') {
applySeenPayload(parsed);
return;
@@ -959,9 +1015,39 @@ export function useOrgChat() {
pageOpen.value = open;
}
function normalizeMessageList(
list: Record<string, unknown>[],
roomId: string,
hiddenSet: Set<string>,
myId: string,
): ChatMessage[] {
for (const item of list) {
const rawContent = String(item.content ?? item.text ?? item.body ?? '').trim();
const target = parseHideCommand(rawContent);
if (target) hiddenSet.add(target);
}
rememberHidden(roomId, [...hiddenSet]);
const normalized: ChatMessage[] = [];
for (const item of list) {
const m = normalizeIncoming(item, roomId);
if (!m || hiddenSet.has(m.id)) continue;
normalized.push(
m.senderId === myId
? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery }
: m,
);
}
return sortAsc(normalized);
}
async function loadMessages(roomId: string) {
activeRoomId.value = roomId;
loadingMessages.value = true;
loadingOlderMessages.value = false;
fetchedRawCount = 0;
hasMoreMessages.value = false;
clearTypingForRoom(roomId);
startHiddenPoll(roomId);
try {
const session = await ensureSession();
@@ -981,35 +1067,72 @@ export function useOrgChat() {
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
// Collect hide-command targets from history so we filter even without CRM row yet
for (const item of list as Record<string, unknown>[]) {
const rawContent = String(
item.content ?? item.text ?? item.body ?? '',
).trim();
const target = parseHideCommand(rawContent);
if (target) hiddenSet.add(target);
}
rememberHidden(roomId, [...hiddenSet]);
const myId = session.chatUserId;
const normalized: ChatMessage[] = [];
for (const item of list as Record<string, unknown>[]) {
const m = normalizeIncoming(item, roomId);
if (!m || hiddenSet.has(m.id)) continue;
normalized.push(
m.senderId === myId
? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery }
: m,
);
}
messages.value = sortAsc(normalized);
const rows = list as Record<string, unknown>[];
fetchedRawCount = rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
messages.value = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
sendRead(roomId);
} finally {
loadingMessages.value = false;
}
}
async function loadOlderMessages(roomId: string): Promise<boolean> {
if (!roomId || loadingOlderMessages.value || loadingMessages.value) return false;
if (!hasMoreMessages.value) return false;
loadingOlderMessages.value = true;
try {
const session = await ensureSession();
const hiddenIds = await fetchHiddenFromServer(roomId);
const hiddenSet = new Set(hiddenIds);
const params = new URLSearchParams({
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
room_id: roomId,
user_id: session.chatUserId,
limit: String(PAGE_SIZE),
offset: String(fetchedRawCount),
});
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('older messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
const rows = list as Record<string, unknown>[];
fetchedRawCount += rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
if (!rows.length) return false;
const older = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
const existingIds = new Set(messages.value.map((m) => m.id));
const fresh = older.filter((m) => !existingIds.has(m.id));
messages.value = sortAsc([...fresh, ...messages.value]);
return true;
} finally {
loadingOlderMessages.value = false;
}
}
let lastTypingSentAt = 0;
async function sendTyping(roomId: string) {
const rid = String(roomId || '').trim();
if (!rid) return;
const now = Date.now();
if (now - lastTypingSentAt < 1800) return;
lastTypingSentAt = now;
try {
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ type: 'typing', room_id: rid }));
} catch {
// ignore
}
}
function typingUserIdsForRoom(roomId: string): string[] {
const list = typingMap.value[roomId] || [];
const now = Date.now();
return list.filter((t) => now - t.at < 3500).map((t) => t.userId);
}
async function sendText(roomId: string, text: string, reply: string | null = null) {
const trimmed = text.trim();
if (!trimmed) return;
@@ -1398,7 +1521,13 @@ export function useOrgChat() {
messages: computed(() => messages.value),
unreadTotal: computed(() => unreadTotal.value),
loadingMessages,
hasMoreMessages: computed(() => hasMoreMessages.value),
loadingOlderMessages: computed(() => loadingOlderMessages.value),
typingMap: computed(() => typingMap.value),
loadMessages,
loadOlderMessages,
sendTyping,
typingUserIdsForRoom,
sendText,
sendFile,
editMessage,
+348 -1
View File
@@ -10,6 +10,7 @@ import type {
ConversationPeerDto,
ConversationPinnedMessageDto,
ConversationRoomDto,
ConversationRoomMemberDto,
CreateActivityRequest,
} from '@mento/shared';
import { formatClockFa, toFaDigits } from '@/lib/jalali';
@@ -78,8 +79,13 @@ const CHAT_EMOJIS = [
const {
messages,
loadingMessages,
hasMoreMessages,
loadingOlderMessages,
status,
loadMessages,
loadOlderMessages,
sendTyping,
typingUserIdsForRoom,
sendText,
sendFile,
editMessage,
@@ -121,6 +127,12 @@ const activityType = ref<ActivityType>('task');
const activityBusy = ref(false);
const activityError = ref('');
const activityCreatedId = ref('');
const showMembers = ref(false);
const roomMembers = ref<ConversationRoomMemberDto[]>([]);
const membersBusy = ref(false);
const membersError = ref('');
const memberAddId = ref('');
const typingTick = ref(0);
const ACTIVITY_TYPE_OPTS: Array<{ value: ActivityType; label: string }> = [
{ value: 'task', label: 'وظیفه' },
@@ -153,6 +165,32 @@ const selectedChannelPeers = computed(() =>
peers.value.filter((p) => channelMemberIds.value.includes(p.id)),
);
const typingLabel = computed(() => {
void typingTick.value;
const roomId = activeRoom.value?.externalRoomId;
if (!roomId) return '';
const ids = typingUserIdsForRoom(roomId);
if (!ids.length) return '';
const names = ids
.map((id) => peers.value.find((p) => p.chatUserId === id)?.fullName)
.filter((n): n is string => Boolean(n));
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;
return room.canManageMembers === true;
});
const membersAddCandidates = computed(() => {
const taken = new Set(roomMembers.value.map((m) => m.userId));
return peers.value.filter((p) => !taken.has(p.id));
});
async function scrollToBottom() {
await nextTick();
const el = msgStreamEl.value;
@@ -616,6 +654,128 @@ function insertMention(peer: ConversationPeerDto) {
function onDraftInput() {
updateMentionState();
if (activeRoom.value && draft.value.trim()) {
void sendTyping(activeRoom.value.externalRoomId);
}
}
async function onMsgStreamScroll() {
const el = msgStreamEl.value;
if (!el || !activeRoom.value) return;
if (el.scrollTop > 80) return;
if (!hasMoreMessages.value || loadingOlderMessages.value) return;
const prevHeight = el.scrollHeight;
const prevTop = el.scrollTop;
const loaded = await loadOlderMessages(activeRoom.value.externalRoomId);
if (!loaded) return;
await nextTick();
el.scrollTop = el.scrollHeight - prevHeight + prevTop;
}
async function openMembersPanel() {
if (!activeRoom.value || activeRoom.value.kind === 'dm') return;
showMembers.value = true;
membersError.value = '';
memberAddId.value = '';
await refreshRoomMembers();
}
async function refreshRoomMembers() {
if (!activeRoom.value) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.get<ConversationRoomMemberDto[]>('/conversations/rooms/members', {
params: { externalRoomId: activeRoom.value.externalRoomId },
});
roomMembers.value = Array.isArray(data) ? data : [];
} catch (e) {
membersError.value =
e instanceof Error ? e.message : 'بارگذاری اعضا ناموفق بود.';
} finally {
membersBusy.value = false;
}
}
async function addRoomMember() {
if (!activeRoom.value || !memberAddId.value) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>('/conversations/rooms/members', {
externalRoomId: activeRoom.value.externalRoomId,
userId: memberAddId.value,
role: 'member',
});
roomMembers.value = Array.isArray(data) ? data : [];
memberAddId.value = '';
// refresh canPost/canManage for current user list
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'افزودن عضو ناموفق بود.';
} else {
membersError.value = 'افزودن عضو ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function removeRoomMember(userId: string) {
if (!activeRoom.value || !canManageActiveMembers.value) return;
if (!window.confirm('این عضو از گفتگو حذف شود؟')) return;
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members/remove',
{
externalRoomId: activeRoom.value.externalRoomId,
userId,
},
);
roomMembers.value = Array.isArray(data) ? data : [];
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'حذف عضو ناموفق بود.';
} else {
membersError.value = 'حذف عضو ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function toggleMemberAdmin(member: ConversationRoomMemberDto) {
if (!activeRoom.value || !canManageActiveMembers.value) return;
const nextRole = member.role === 'admin' ? 'member' : 'admin';
membersBusy.value = true;
membersError.value = '';
try {
const { data } = await api.post<ConversationRoomMemberDto[]>(
'/conversations/rooms/members/role',
{
externalRoomId: activeRoom.value.externalRoomId,
userId: member.userId,
role: nextRole,
},
);
roomMembers.value = Array.isArray(data) ? data : [];
void refreshRoomsSoft();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
membersError.value = Array.isArray(msg) ? msg.join('، ') : msg || 'تغییر نقش ناموفق بود.';
} else {
membersError.value = 'تغییر نقش ناموفق بود.';
}
} finally {
membersBusy.value = false;
}
}
async function confirmDeleteMessage(msg: ChatMessage) {
@@ -916,6 +1076,10 @@ async function refreshRoomsSoft() {
try {
const { data } = await api.get<ConversationRoomDto[]>('/conversations/rooms');
rooms.value = data;
if (activeRoom.value) {
const next = data.find((r) => r.externalRoomId === activeRoom.value!.externalRoomId);
if (next) activeRoom.value = { ...next, unreadCount: 0 };
}
await refreshUnreadTotal();
} catch {
// keep previous
@@ -1274,17 +1438,23 @@ watch(chatSearchHits, (hits) => {
nextTick(() => scrollToMessage(activeSearchHitId.value));
});
let typingUiTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
setConversationsPageOpen(true);
stopRoomLive = onRoomLiveEvent(applyRoomLive);
stopPinLive = onMessagePinEvent(applyMessagePin);
void bootstrap();
typingUiTimer = setInterval(() => {
typingTick.value += 1;
}, 1000);
});
onUnmounted(() => {
setConversationsPageOpen(false);
stopRoomLive?.();
stopPinLive?.();
if (typingUiTimer) clearInterval(typingUiTimer);
clearActiveRoom();
clearPendingAttaches();
if (recording.value) mediaRecorder?.stop();
@@ -1511,6 +1681,15 @@ onUnmounted(() => {
}}</span>
</div>
<div class="stage-tools">
<button
v-if="activeRoom.kind !== 'dm'"
type="button"
class="select-act"
title="اعضا"
@click="openMembersPanel"
>
<UiIcon name="users" :size="18" />
</button>
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
<UiIcon name="search" :size="18" />
</button>
@@ -1550,7 +1729,15 @@ onUnmounted(() => {
</div>
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیام‌ها…</div>
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
<ul
v-else
ref="msgStreamEl"
class="msg-stream"
:class="{ selecting: selectMode }"
@scroll.passive="onMsgStreamScroll"
>
<li v-if="loadingOlderMessages" class="history-loading">در حال بارگذاری پیام‌های قبلی…</li>
<li v-else-if="hasMoreMessages" class="history-hint">برای دیدن پیام‌های قدیمی‌تر بالا بکشید</li>
<li
v-for="msg in messages"
:key="msg.id"
@@ -1683,6 +1870,10 @@ onUnmounted(() => {
</li>
</ul>
<div v-if="typingLabel" class="typing-bar">
<span class="typing-dots" aria-hidden="true"><i /><i /><i /></span>
{{ typingLabel }}
</div>
<div v-if="!canPostInActive" class="channel-readonly">
<UiIcon name="megaphone" :size="18" />
<span>فقط مدیران کانال می‌توانند پیام بفرستند</span>
@@ -2112,6 +2303,64 @@ onUnmounted(() => {
</div>
</dialog>
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
<div class="modal-card wide members-sheet">
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
<p v-if="activeRoom?.kind === 'channel'" class="hint channel-hint">
ادمین‌ها می‌توانند در کانال پیام بفرستند؛ بقیه فقط مشاهده می‌کنند.
</p>
<div v-if="canManageActiveMembers" class="member-add-row">
<select v-model="memberAddId" :disabled="membersBusy || !membersAddCandidates.length">
<option value="">افزودن همکار…</option>
<option v-for="p in membersAddCandidates" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
<button
type="button"
class="primary"
:disabled="membersBusy || !memberAddId"
@click="addRoomMember"
>
افزودن
</button>
</div>
<p v-if="membersError" class="act-err">{{ membersError }}</p>
<div v-if="membersBusy && !roomMembers.length" class="muted center pad">در حال بارگذاری…</div>
<div v-else class="peer-list members-list">
<div v-for="m in roomMembers" :key="m.userId" class="peer-row member-static">
<UserAvatar :name="m.fullName" :size="40" />
<span class="peer-row-meta">
<strong>{{ m.fullName }}{{ m.isMe ? ' (شما)' : '' }}</strong>
<small>{{ m.role === 'admin' ? 'ادمین' : 'عضو' }}</small>
</span>
<div v-if="canManageActiveMembers && !m.isMe" class="member-acts">
<button
type="button"
class="member-act"
:disabled="membersBusy"
@click="toggleMemberAdmin(m)"
>
{{ m.role === 'admin' ? 'برداشتن ادمین' : 'ادمین کردن' }}
</button>
<button
type="button"
class="member-act danger"
:disabled="membersBusy"
@click="removeRoomMember(m.userId)"
>
حذف
</button>
</div>
</div>
<p v-if="!roomMembers.length" class="peer-empty">عضوی ثبت نشده</p>
</div>
<div class="modal-actions">
<button type="button" @click="showMembers = false">بستن</button>
</div>
</div>
</dialog>
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
<div class="modal-card">
<h3>ارسال برای دیگری</h3>
@@ -2672,6 +2921,104 @@ onUnmounted(() => {
flex-shrink: 0;
text-align: center;
}
.typing-bar {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 1rem;
font-size: 0.8rem;
color: #0f766e;
background: rgba(13, 148, 136, 0.06);
border-top: 1px solid rgba(13, 148, 136, 0.12);
flex-shrink: 0;
}
.typing-dots {
display: inline-flex;
gap: 3px;
align-items: center;
}
.typing-dots i {
width: 5px;
height: 5px;
border-radius: 999px;
background: #0d9488;
animation: typingPulse 1.2s infinite ease-in-out;
}
.typing-dots i:nth-child(2) {
animation-delay: 0.15s;
}
.typing-dots i:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes typingPulse {
0%,
80%,
100% {
opacity: 0.3;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
.history-loading,
.history-hint {
text-align: center;
color: var(--muted);
font-size: 0.78rem;
padding: 0.35rem 0.5rem 0.75rem;
list-style: none;
}
.member-add-row {
display: flex;
gap: 0.45rem;
align-items: center;
}
.member-add-row select {
flex: 1;
min-width: 0;
padding: 0.55rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--line);
font: inherit;
}
.member-add-row .primary {
border: none;
background: #0d9488;
color: #fff;
border-radius: 10px;
padding: 0.55rem 0.85rem;
font: inherit;
cursor: pointer;
white-space: nowrap;
}
.member-static {
cursor: default;
}
.member-acts {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: stretch;
}
.member-act {
border: 1px solid var(--line);
background: #fff;
border-radius: 8px;
padding: 0.25rem 0.45rem;
font: inherit;
font-size: 0.72rem;
cursor: pointer;
white-space: nowrap;
color: #0f766e;
}
.member-act.danger {
color: #b91c1c;
}
.members-list {
max-height: 280px;
}
.msg-stream {
flex: 1;
list-style: none;