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
+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>