Aligning the chat bar with Telegram

This commit is contained in:
2026-09-29 21:06:04 +03:30
parent e20b904bbe
commit c20dc0a7ca
2 changed files with 536 additions and 42 deletions
+150 -4
View File
@@ -22,6 +22,8 @@ export type ChatMessage = {
fileName?: string;
uploadProgress?: number;
localPreviewUrl?: string;
edited?: boolean;
deleted?: boolean;
};
export type RoomLiveEvent = {
@@ -299,6 +301,13 @@ function normalizeIncoming(
const type = inferMediaType(declared, content, fileName);
const isMedia = type !== 'text';
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
const deleted = Boolean(
base.deleted ||
base.is_deleted ||
base.isDeleted ||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content),
);
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
return {
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -306,16 +315,18 @@ function normalizeIncoming(
senderId: String(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
),
content,
type,
content: deleted ? 'این پیام حذف شد' : content,
type: deleted ? 'text' : type,
createdAt: String(
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
),
reply: base.reply ? String(base.reply) : null,
readAt: readAt ? String(readAt) : null,
delivery: readAt ? 'read' : 'sent',
mediaUrl,
fileName,
mediaUrl: deleted ? undefined : mediaUrl,
fileName: deleted ? undefined : fileName,
edited: deleted ? false : edited,
deleted,
};
}
@@ -340,6 +351,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: incoming.deleted || prev.deleted,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
@@ -379,6 +392,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
uploadProgress: undefined,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: incoming.deleted || prev.deleted,
delivery: incoming.readAt ? 'read' : 'sent',
};
if (prev.localPreviewUrl?.startsWith('blob:')) {
@@ -496,6 +511,53 @@ function handleIncomingMessage(msg: ChatMessage) {
});
}
function applyEditedMessage(payload: Record<string, unknown>) {
const base =
payload.message && typeof payload.message === 'object'
? (payload.message as Record<string, unknown>)
: payload;
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
if (!id) return;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content) return;
messages.value = messages.value.map((m) =>
m.id === id
? {
...m,
content,
type: 'text',
edited: true,
deleted: false,
mediaUrl: undefined,
fileName: undefined,
}
: m,
);
}
function applyDeletedMessage(payload: Record<string, unknown>) {
const base =
payload.message && typeof payload.message === 'object'
? (payload.message as Record<string, unknown>)
: payload;
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
if (!id) return;
messages.value = messages.value.map((m) =>
m.id === id
? {
...m,
content: 'این پیام حذف شد',
type: 'text',
deleted: true,
edited: false,
mediaUrl: undefined,
fileName: undefined,
localPreviewUrl: undefined,
}
: m,
);
}
function onSocketMessage(event: MessageEvent) {
const raw = String(event.data ?? '').trim();
if (!raw || raw === 'pong' || raw === 'ping') return;
@@ -507,6 +569,28 @@ function onSocketMessage(event: MessageEvent) {
applySeenPayload(parsed);
return;
}
if (
type === 'edit' ||
type === 'edited' ||
type === 'update' ||
type === 'message_edit' ||
type === 'message:edit' ||
type === 'message_edited'
) {
applyEditedMessage(parsed);
return;
}
if (
type === 'delete' ||
type === 'deleted' ||
type === 'remove' ||
type === 'message_delete' ||
type === 'message:delete' ||
type === 'message_deleted'
) {
applyDeletedMessage(parsed);
return;
}
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg) return;
handleIncomingMessage(msg);
@@ -916,6 +1000,66 @@ export function useOrgChat() {
}
}
async function editMessage(roomId: string, messageId: string, text: string) {
const trimmed = text.trim();
if (!trimmed || !messageId) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
messages.value = messages.value.map((m) =>
m.id === messageId
? {
...m,
content: trimmed,
type: 'text',
edited: true,
deleted: false,
mediaUrl: undefined,
fileName: undefined,
}
: m,
);
socket.send(
JSON.stringify({
type: 'edit',
id: messageId,
room_id: roomId,
content: trimmed,
}),
);
emitRoomLive({
roomId,
preview: trimmed,
at: new Date().toISOString(),
fromSelf: true,
});
}
async function deleteMessage(roomId: string, messageId: string) {
if (!messageId) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
applyDeletedMessage({ id: messageId });
socket.send(
JSON.stringify({
type: 'delete',
id: messageId,
room_id: roomId,
}),
);
emitRoomLive({
roomId,
preview: 'این پیام حذف شد',
at: new Date().toISOString(),
fromSelf: true,
});
}
function clearActiveRoom() {
activeRoomId.value = '';
messages.value = [];
@@ -937,6 +1081,8 @@ export function useOrgChat() {
loadMessages,
sendText,
sendFile,
editMessage,
deleteMessage,
sendRead,
ensureConnected,
setConversationsPageOpen,
+386 -38
View File
@@ -46,6 +46,17 @@ const recording = ref(false);
const recordSecs = ref(0);
const lightboxUrl = ref('');
const fileInputEl = ref<HTMLInputElement | null>(null);
const draftInputEl = ref<HTMLInputElement | null>(null);
const emojiOpen = ref(false);
const editingMessageId = ref<string | null>(null);
const msgMenu = ref<{ id: string; x: number; y: number } | null>(null);
const CHAT_EMOJIS = [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
];
const {
messages,
@@ -54,6 +65,8 @@ const {
loadMessages,
sendText,
sendFile,
editMessage,
deleteMessage,
sendRead,
ensureConnected,
setConversationsPageOpen,
@@ -116,10 +129,107 @@ function closeMobileChat() {
clearActiveRoom();
activeRoom.value = null;
clearPendingAttaches();
cancelEdit();
emojiOpen.value = false;
msgMenu.value = null;
if (recording.value) mediaRecorder?.stop();
}
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
const canSend = computed(
() => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value),
);
const showMicInsteadOfSend = computed(
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
);
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;
}
function startEditMessage(msg: ChatMessage) {
if (msg.deleted || msg.type !== 'text' || msg.optimistic) return;
editingMessageId.value = msg.id;
draft.value = msg.content;
emojiOpen.value = false;
msgMenu.value = null;
clearPendingAttaches();
nextTick(() => draftInputEl.value?.focus());
}
async function confirmDeleteMessage(msg: ChatMessage) {
msgMenu.value = null;
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
if (!window.confirm('این پیام حذف شود؟')) return;
try {
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
if (editingMessageId.value === msg.id) cancelEdit();
} catch (e) {
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
}
}
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
const mine = msg.senderId === chatUserId.value || msg.optimistic;
if (!mine || msg.deleted) return;
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;
}
const pad = 12;
x = Math.min(Math.max(x, pad + 80), window.innerWidth - pad - 80);
y = Math.min(Math.max(y, pad + 40), window.innerHeight - pad);
msgMenu.value = { id: msg.id, x, y };
}
function menuMessage(): ChatMessage | null {
if (!msgMenu.value) return null;
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
}
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();
}
function classifyPending(file: File): PendingAttach['kind'] {
return classifyChatFile(file);
@@ -354,6 +464,25 @@ async function submitMessage() {
if (sending.value || recording.value) return;
const text = draft.value.trim();
const attaches = [...pendingAttaches.value];
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;
}
if (!text && !attaches.length) return;
sending.value = true;
@@ -361,6 +490,7 @@ async function submitMessage() {
const roomId = activeRoom.value.externalRoomId;
draft.value = '';
pendingAttaches.value = [];
emojiOpen.value = false;
try {
if (text) {
@@ -556,9 +686,23 @@ onUnmounted(() => {
:key="msg.id"
class="msg-row"
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
@contextmenu="openMsgMenu(msg, $event)"
>
<div class="bubble" :class="{ media: msg.type !== 'text' }">
<template v-if="msg.type === 'image'">
<div
class="bubble"
:class="{
media: msg.type !== 'text' && !msg.deleted,
deleted: msg.deleted,
}"
@touchstart.passive="onBubbleTouchStart(msg, $event)"
@touchend="onBubbleTouchEnd"
@touchmove="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd"
>
<template v-if="msg.deleted">
<p class="deleted-text">این پیام حذف شد</p>
</template>
<template v-else-if="msg.type === 'image'">
<button
type="button"
class="media-image"
@@ -617,6 +761,7 @@ onUnmounted(() => {
</template>
<p v-else>{{ msg.content }}</p>
<div class="bubble-meta">
<span v-if="msg.edited && !msg.deleted" class="edited-mark">ویرایش‌شده</span>
<time>{{ formatListTime(msg.createdAt) }}</time>
<span
v-if="msg.senderId === chatUserId || msg.optimistic"
@@ -636,6 +781,22 @@ onUnmounted(() => {
</ul>
<form class="composer" @submit.prevent="submitMessage">
<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>
<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" />
@@ -665,40 +826,86 @@ onUnmounted(() => {
/>
<button
type="button"
class="tool-btn"
title="پیوست فایل / عکس / فیلم"
:disabled="sending || recording"
class="tool-btn attach-btn"
title="پیوست"
:disabled="sending || recording || Boolean(editingMessageId)"
@click="fileInputEl?.click()"
>
<UiIcon name="document" :size="18" />
<UiIcon name="plus" :size="20" />
</button>
<div class="input-shell">
<button
type="button"
class="emoji-toggle"
title="ایموجی"
:class="{ on: emojiOpen }"
:disabled="sending || recording"
@click="emojiOpen = !emojiOpen"
>
😊
</button>
<input
ref="draftInputEl"
v-model="draft"
type="text"
class="composer-input"
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
autocomplete="off"
enterkeyhint="send"
:disabled="sending || recording"
@focus="emojiOpen = false"
/>
</div>
<button
v-if="showMicInsteadOfSend"
type="button"
class="tool-btn"
class="tool-btn mic-btn"
:class="{ on: recording }"
title="ضبط پیام صوتی"
:disabled="sending"
@click="toggleVoiceRecord"
>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
</button>
<input
v-model="draft"
type="text"
class="composer-input"
placeholder="پیام…"
autocomplete="off"
:disabled="sending || recording"
/>
<button
v-else
type="submit"
class="send-btn"
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
:disabled="sending || recording || !canSend"
>
<UiIcon name="send" :size="18" />
</button>
</div>
</form>
<div
v-if="msgMenu"
class="msg-menu-backdrop"
@click="msgMenu = null"
@touchstart.passive="msgMenu = null"
/>
<div
v-if="msgMenu && menuMessage()"
class="msg-menu"
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
>
<button
v-if="menuMessage()?.type === 'text' && !menuMessage()?.optimistic"
type="button"
@click="startEditMessage(menuMessage()!)"
>
<UiIcon name="edit" :size="16" />
ویرایش
</button>
<button
type="button"
class="danger"
@click="confirmDeleteMessage(menuMessage()!)"
>
<UiIcon name="trash" :size="16" />
حذف
</button>
</div>
</template>
</section>
</div>
@@ -1118,42 +1325,121 @@ onUnmounted(() => {
.composer {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
gap: 0.4rem;
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.92);
background: #f0f2f5;
flex-shrink: 0;
}
.composer-row {
display: flex;
gap: 0.4rem;
align-items: center;
gap: 0.35rem;
align-items: flex-end;
min-width: 0;
}
.input-shell {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.15rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 22px;
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
min-height: 2.75rem;
}
.composer-input {
flex: 1 1 auto;
min-width: 0;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.65rem 0.85rem;
border: none;
outline: none;
border-radius: 18px;
padding: 0.55rem 0.35rem;
font: inherit;
font-size: 0.95rem;
background: transparent;
}
.emoji-toggle {
flex: 0 0 auto;
border: none;
background: transparent;
width: 2.1rem;
height: 2.1rem;
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);
}
.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;
background: #fff;
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;
}
.tool-btn,
.send-btn {
flex: 0 0 auto;
border-radius: 12px;
width: 2.6rem;
height: 2.6rem;
min-width: 2.6rem;
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
display: grid;
place-items: center;
cursor: pointer;
}
.tool-btn {
border: 1px solid var(--line);
background: var(--surface, #fff);
color: var(--text);
border: none;
background: transparent;
color: #64748b;
}
.tool-btn.attach-btn,
.tool-btn.mic-btn {
background: #fff;
border: 1px solid #e2e8f0;
color: #334155;
}
.tool-btn.on {
border-color: #ef4444;
@@ -1169,6 +1455,60 @@ onUnmounted(() => {
border: none;
background: #0d9488;
color: #fff;
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;
z-index: 40;
background: transparent;
}
.msg-menu {
position: fixed;
z-index: 50;
transform: translate(-50%, -110%);
min-width: 10rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.35rem;
border-radius: 14px;
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
}
.msg-menu button {
display: flex;
align-items: center;
gap: 0.45rem;
border: none;
background: transparent;
padding: 0.55rem 0.7rem;
border-radius: 10px;
font: inherit;
cursor: pointer;
color: #0f172a;
text-align: right;
}
.msg-menu button:hover,
.msg-menu button:active {
background: #f8fafc;
}
.msg-menu button.danger {
color: #dc2626;
}
.attach-preview {
display: flex;
@@ -1484,19 +1824,27 @@ onUnmounted(() => {
max-width: min(300px, 82%);
}
.composer {
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
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);
}
.tool-btn,
.send-btn {
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
width: 2.85rem;
height: 2.85rem;
min-width: 2.85rem;
}
.input-shell {
min-height: 2.85rem;
}
.composer-input {
padding: 0.7rem 0.8rem;
padding: 0.6rem 0.3rem;
font-size: 1rem;
}
.emoji-panel {
grid-template-columns: repeat(8, minmax(0, 1fr));
max-height: 11rem;
}
}
</style>